Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable PIN registration with query param #2711

Merged
merged 6 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ export const PORTAL_II_URL = "https://internetcomputer.org/internet-identity";
// Default support page URL for when error is shown to user
export const ERROR_SUPPORT_URL =
"https://identitysupport.dfinity.org/hc/en-us/articles/32301362727188";

// Pin is disallowed by default unless this query parameter is set.
// This is used for testing purposes because we still support logging in with PIN but not registering with it.
export const ENABLE_PIN_QUERY_PARAM_KEY = "enablePin";
1 change: 1 addition & 0 deletions src/frontend/src/flows/authorize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ const authenticate = async (
dapp.hasOrigin(authContext.requestOrigin)
),
}),
// This allows logging in with a PIN but not registering with a PIN
allowPinAuthentication:
authContext.authRequest.allowPinAuthentication ?? true,
autoSelectionIdentity: autoSelectionIdentity,
Expand Down
9 changes: 6 additions & 3 deletions src/frontend/src/flows/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { withLoader } from "$src/components/loader";
import { logoutSection } from "$src/components/logout";
import { mainWindow } from "$src/components/mainWindow";
import { toast } from "$src/components/toast";
import { LEGACY_II_URL } from "$src/config";
import { ENABLE_PIN_QUERY_PARAM_KEY, LEGACY_II_URL } from "$src/config";
import { addDevice } from "$src/flows/addDevice/manage/addDevice";
import { dappsExplorer } from "$src/flows/dappsExplorer";
import { KnownDapp, getDapps } from "$src/flows/dappsExplorer/dapps";
Expand Down Expand Up @@ -96,6 +96,10 @@ export const authFlowManage = async (connection: Connection) => {
const i18n = new I18n();
const dapps = shuffleArray(getDapps());

const params = new URLSearchParams(window.location.search);
const allowPinAuthentication =
params.get(ENABLE_PIN_QUERY_PARAM_KEY) !== null;

const identityBackground = new PreLoadImage(identityCardBackground);
// Go through the login flow, potentially creating an anchor.
const {
Expand All @@ -106,8 +110,7 @@ export const authFlowManage = async (connection: Connection) => {
connection,
i18n,
templates: authnTemplateManage({ dapps }),
allowPinAuthentication:
true /* when authenticating to II directly we always allow pin */,
allowPinAuthentication,
});

// Here, if the user is returning & doesn't have any recovery device, we prompt them to add
Expand Down
115 changes: 95 additions & 20 deletions src/frontend/src/flows/register/index.ts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this PR are mainly reverting #2687

Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { AuthnMethodData } from "$generated/internet_identity_types";
import { withLoader } from "$src/components/loader";
import { PinIdentityMaterial } from "$src/crypto/pinIdentity";
import { ENABLE_PIN_QUERY_PARAM_KEY } from "$src/config";
import {
PinIdentityMaterial,
constructPinIdentity,
} from "$src/crypto/pinIdentity";
import { idbStorePinIdentityMaterial } from "$src/flows/pin/idb";
import { registerDisabled } from "$src/flows/registerDisabled";
import { I18n } from "$src/i18n";
import { setAnchorUsed } from "$src/storage";
import { passkeyAuthnMethodData } from "$src/utils/authnMethodData";
import {
passkeyAuthnMethodData,
pinAuthnMethodData,
} from "$src/utils/authnMethodData";
import {
AlreadyInProgress,
ApiError,
Expand All @@ -23,7 +31,10 @@ import {
import { SignIdentity } from "@dfinity/agent";
import { ECDSAKeyIdentity } from "@dfinity/identity";
import { nonNullish } from "@dfinity/utils";
import { TemplateResult } from "lit-html";
import type { UAParser } from "ua-parser-js";
import { tempKeyWarningBox } from "../manage/tempKeys";
import { setPinFlow } from "../pin/setPin";
import { precomputeFirst, promptCaptcha } from "./captcha";
import { displayUserNumberWarmup } from "./finish";
import { savePasskeyOrPin } from "./passkey";
Expand All @@ -33,9 +44,9 @@ export const registerFlow = async ({
identityRegistrationStart,
checkCaptcha,
identityRegistrationFinish,
storePinIdentity: _storePinIdentity,
storePinIdentity,
registrationAllowed,
pinAllowed: _pinAllowed,
pinAllowed,
uaParser,
}: {
identityRegistrationStart: () => Promise<
Expand Down Expand Up @@ -98,24 +109,82 @@ export const registerFlow = async ({
const flowStart = precomputeFirst(() => identityRegistrationStart());

const displayUserNumber = displayUserNumberWarmup();
const identity = await savePasskeyOrPin();
if (identity === undefined) {
// TODO: Return something meaningful if getting the identity fails
const savePasskeyResult = await savePasskeyOrPin({
pinAllowed: await pinAllowed(),
});
if (savePasskeyResult === "canceled") {
return "canceled";
}
const alias = await inferPasskeyAlias({
authenticatorType: identity.getAuthenticatorAttachment(),
userAgent: navigator.userAgent,
uaParser,
});
const result_ = await (async () => {
if (savePasskeyResult === "pin") {
const pinResult = await setPinFlow();
if (pinResult.tag === "canceled") {
return "canceled";
}

const authnMethodData = passkeyAuthnMethodData({
alias,
pubKey: identity.getPublicKey().toDer(),
credentialId: identity.rawId,
authenticatorAttachment: identity.getAuthenticatorAttachment(),
});
const authnMethod = "passkey" as const;
pinResult.tag satisfies "ok";

// XXX: this withLoader could be replaced with one that indicates what's happening (like the
// "Hang tight, ..." spinner)
const { identity, pinIdentityMaterial } = await withLoader(() =>
constructPinIdentity(pinResult)
);
const alias = await inferPinAlias({
userAgent: navigator.userAgent,
uaParser,
});
return {
identity,
authnMethodData: pinAuthnMethodData({
alias,
pubKey: identity.getPublicKey().toDer(),
}),
finalizeIdentity: (userNumber: bigint) =>
storePinIdentity({ userNumber, pinIdentityMaterial }),
finishSlot: tempKeyWarningBox({ i18n: new I18n() }),
authnMethod: "pin" as const,
};
} else {
const identity = savePasskeyResult;
// TODO: Return something meaningful if getting the passkey identity fails
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only new line that was not just reverted. Because the view can return undefined.

This is in the current code as well. The way this error was handled before was to never resolve the promise and show a toast error.

if (identity === undefined) {
return "canceled";
}
const alias = await inferPasskeyAlias({
authenticatorType: identity.getAuthenticatorAttachment(),
userAgent: navigator.userAgent,
uaParser,
});
return {
identity,
authnMethodData: passkeyAuthnMethodData({
alias,
pubKey: identity.getPublicKey().toDer(),
credentialId: identity.rawId,
authenticatorAttachment: identity.getAuthenticatorAttachment(),
}),
authnMethod: "passkey" as const,
};
}
})();

if (result_ === "canceled") {
return "canceled";
}

const {
identity,
authnMethodData,
finalizeIdentity,
finishSlot,
authnMethod,
}: {
identity: SignIdentity;
authnMethodData: AuthnMethodData;
finalizeIdentity?: (userNumber: bigint) => Promise<void>;
finishSlot?: TemplateResult;
authnMethod: "pin" | "passkey";
} = result_;

const startResult = await flowStart();
if (startResult.kind !== "registrationFlowStepSuccess") {
Expand Down Expand Up @@ -150,6 +219,7 @@ export const registerFlow = async ({
result.kind satisfies "loginSuccess";

const userNumber = result.userNumber;
await finalizeIdentity?.(userNumber);
// We don't want to nudge the user with the recovery phrase warning page
// right after they've created their anchor.
result.connection.updateIdentityMetadata({
Expand All @@ -162,6 +232,7 @@ export const registerFlow = async ({
);
await displayUserNumber({
userNumber,
marketingIntroSlot: finishSlot,
});
return { ...result, authnMethod };
};
Expand All @@ -180,6 +251,10 @@ export const getRegisterFlowOpts = async ({
const tempIdentity = await ECDSAKeyIdentity.generate({
extractable: false,
});
const params = new URLSearchParams(window.location.search);
// Only allow PIN if query param is set and the request allows it
const allowPinRegistration =
params.get(ENABLE_PIN_QUERY_PARAM_KEY) !== null && allowPinAuthentication;
return {
/** Check that the current origin is not the explicit canister id or a raw url.
* Explanation why we need to do this:
Expand All @@ -192,7 +267,7 @@ export const getRegisterFlowOpts = async ({
pinAllowed: () =>
// If pin auth is disallowed by the authenticating dapp then abort, otherwise check
// if pin auth is allowed for the user agent
allowPinAuthentication
allowPinRegistration
? pinRegisterAllowed({ userAgent: navigator.userAgent, uaParser })
: Promise.resolve(false),
identityRegistrationStart: async () =>
Expand Down
29 changes: 25 additions & 4 deletions src/frontend/src/flows/register/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,32 @@ const savePasskeyTemplate = ({
export const savePasskeyPage = renderPage(savePasskeyTemplate);

// Prompt the user to create a WebAuthn identity or a PIN identity (if allowed)
export const savePasskeyOrPin = async (): Promise<
IIWebAuthnIdentity | undefined
> => {
export const savePasskeyOrPin = async ({
Copy link
Collaborator Author

@lmuntaner lmuntaner Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is reverted to the old logic if pinAllowed is true and skip to render the page if not enabled.

pinAllowed,
}: {
pinAllowed: boolean;
}): Promise<IIWebAuthnIdentity | "pin" | "canceled" | undefined> => {
if (pinAllowed) {
return new Promise((resolve) => {
return savePasskeyPage({
i18n: new I18n(),
cancel: () => resolve("canceled"),
scrollToTop: true,
constructPasskey: async () => {
try {
const identity = await withLoader(() => constructIdentity({}));
resolve(identity);
} catch (e) {
toast.error(errorMessage(e));
}
},
constructPin: pinAllowed ? () => resolve("pin") : undefined,
});
});
}
try {
return await withLoader(() => constructIdentity({}));
const identity = await withLoader(() => constructIdentity({}));
return identity;
} catch (e) {
toast.error(errorMessage(e));
return undefined;
Expand Down
71 changes: 22 additions & 49 deletions src/frontend/src/test-e2e/pinAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,39 +12,33 @@ import {
switchToPopup,
wipeStorage,
} from "./util";
import {
AuthenticateView,
DemoAppView,
MainView,
PinAuthView,
RegisterView,
WelcomeView,
} from "./views";
import { AuthenticateView, DemoAppView, MainView, PinAuthView } from "./views";

const DEFAULT_PIN_DEVICE_NAME = "Chrome on Mac OS";
// Same as in frontend/src/config.ts
const ENABLE_PIN_QUERY_PARAM_KEY = "enablePin";

// TODO: GIX-3138 Clean up after release
// TODO: Test login with PIN only GIX-3139
test.skip("PIN registration not enabled on non-Apple device", async () => {
test("PIN registration not enabled on non-Apple device", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
await browser.url(II_URL);
const welcomeView = new WelcomeView(browser);
await welcomeView.waitForDisplay();
await welcomeView.register();
const registerView = new RegisterView(browser);
await registerView.waitForDisplay();
await registerView.assertPinRegistrationNotShown();
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
// The PIN registration flow should not be enabled and go directly to login with passkey
await addVirtualAuthenticator(browser);
await FLOWS.registerNewIdentityWelcomeView(browser);
const mainView = new MainView(browser);
await mainView.waitForDeviceDisplay(DEVICE_NAME1);
await mainView.logout();
}, EDGE_USER_AGENT);
}, 300_000);

// The PIN auth feature is only enabled for Apple specific user agents, so tests set the user
// agent to chrome on macOS

test.skip("Register and Log in with PIN identity", async () => {
test("Register and Log in with PIN identity", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";

await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
const userNumber = await FLOWS.registerPinWelcomeView(browser, pin);
const mainView = new MainView(browser);
await mainView.waitForDisplay(); // we should be logged in
Expand All @@ -55,10 +49,10 @@ test.skip("Register and Log in with PIN identity", async () => {
}, APPLE_USER_AGENT);
}, 300_000);

test.skip("Register with PIN and login without prefilled identity number", async () => {
test("Register with PIN and login without prefilled identity number", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";
await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
const userNumber = await FLOWS.registerPinWelcomeView(browser, pin);

const mainView = new MainView(browser);
Expand All @@ -68,18 +62,18 @@ test.skip("Register with PIN and login without prefilled identity number", async
await wipeStorage(browser);

// load the II page again
await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
await FLOWS.loginPinWelcomeView(userNumber, pin, browser);
await mainView.waitForTempKeyDisplay(DEFAULT_PIN_DEVICE_NAME);
}, APPLE_USER_AGENT);
}, 300_000);

test.skip("Register and log in with PIN identity, retry on wrong PIN", async () => {
test("Register and log in with PIN identity, retry on wrong PIN", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";
const wrongPin = "456321";

await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
const userNumber = await FLOWS.registerPinWelcomeView(browser, pin);
const mainView = new MainView(browser);
await mainView.waitForDisplay(); // we should be logged in
Expand All @@ -100,12 +94,12 @@ test.skip("Register and log in with PIN identity, retry on wrong PIN", async ()
}, APPLE_USER_AGENT);
}, 300_000);

test.skip("Should not prompt for PIN after deleting temp key", async () => {
test("Should not prompt for PIN after deleting temp key", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";
await addVirtualAuthenticator(browser);

await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
const userNumber = await FLOWS.registerPinWelcomeView(browser, pin);
const mainView = new MainView(browser);
await mainView.waitForDisplay(); // we should be logged in
Expand All @@ -123,32 +117,11 @@ test.skip("Should not prompt for PIN after deleting temp key", async () => {
}, APPLE_USER_AGENT);
}, 300_000);

test.skip("Log into client application using PIN registration flow", async () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be enabled in the foreseeable future.

await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";

const demoAppView = new DemoAppView(browser);
await demoAppView.open(TEST_APP_NICE_URL, II_URL);
await demoAppView.waitForDisplay();
expect(await demoAppView.getPrincipal()).toBe("");
await demoAppView.signin();
await switchToPopup(browser);
await FLOWS.registerPinNewIdentityAuthenticateView(pin, browser);

const principal = await demoAppView.waitForAuthenticated();
expect(await demoAppView.whoami()).toBe(principal);

// default value
const exp = await browser.$("#expiration").getText();
expect(Number(exp) / (8 * 60 * 60_000_000_000)).toBeCloseTo(1);
}, APPLE_USER_AGENT);
}, 300_000);

test.skip("Register with PIN then log into client application", async () => {
test("Register with PIN then log into client application", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const pin = "123456";

await browser.url(II_URL);
await browser.url(`${II_URL}?${ENABLE_PIN_QUERY_PARAM_KEY}`);
const userNumber = await FLOWS.registerPinWelcomeView(browser, pin);

const demoAppView = new DemoAppView(browser);
Expand Down