Skip to content

Commit

Permalink
Enable PIN registration with query param (#2711)
Browse files Browse the repository at this point in the history
* Reintroduce PIN registration with query param

* Enable PIN e2e tests

* Fix tests

* Use variable for query param in e2e test

* Fix e2e test

* Remove console.log
  • Loading branch information
lmuntaner authored Nov 27, 2024
1 parent c15d6d2 commit 6000f20
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 76 deletions.
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
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
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 ({
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 () => {
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

0 comments on commit 6000f20

Please sign in to comment.