From 35aaae7151805e9d9db91b9da77fd387ee1b05bf Mon Sep 17 00:00:00 2001 From: Nicolas Mattia Date: Tue, 29 Aug 2023 14:57:30 +0200 Subject: [PATCH] Showcase registration flow This refactors the `register` function to be able to showcase the registration flow without needing to spin up a canister. A type parameter had to be added to `RegisterResult` to allow "mocking" the result without creating an actual canister connection. The flow part is extracted from `register`, which is now a wrapper around this new `registerFlow`; `register` now only deals with canister/concrete implementation details whereas `registerFlow` only focuses on the user interaction. --- src/frontend/src/flows/register/captcha.ts | 61 +++------- src/frontend/src/flows/register/index.ts | 134 +++++++++++++++------ src/frontend/src/utils/iiConnection.ts | 8 +- src/showcase/src/flows.ts | 52 ++++++++ 4 files changed, 169 insertions(+), 86 deletions(-) diff --git a/src/frontend/src/flows/register/captcha.ts b/src/frontend/src/flows/register/captcha.ts index bf01ec0cba..55895e58f2 100644 --- a/src/frontend/src/flows/register/captcha.ts +++ b/src/frontend/src/flows/register/captcha.ts @@ -2,14 +2,8 @@ import { Challenge } from "$generated/internet_identity_types"; import { mainWindow } from "$src/components/mainWindow"; import { DynamicKey, I18n } from "$src/i18n"; import { LoginFlowCanceled, cancel } from "$src/utils/flowResult"; -import { - Connection, - IIWebAuthnIdentity, - RegisterResult, -} from "$src/utils/iiConnection"; import { mount, renderPage, withRef } from "$src/utils/lit-html"; import { Chan } from "$src/utils/utils"; -import { ECDSAKeyIdentity } from "@dfinity/identity"; import { TemplateResult, html } from "lit-html"; import { asyncReplace } from "lit-html/directives/async-replace.js"; import { Ref, createRef, ref } from "lit-html/directives/ref.js"; @@ -262,62 +256,35 @@ export function promptCaptchaPage( )(props, container); } -export const promptCaptcha = ({ - connection, - identity, - alias, - challenge, +export const promptCaptcha = ({ + createChallenge, + register, }: { - connection: Connection; - identity: IIWebAuthnIdentity; - alias: string; - challenge?: Promise; -}): Promise => { + createChallenge: () => Promise; + register: (cr: { + chars: string; + challenge: Challenge; + }) => Promise; +}): Promise => { return new Promise((resolve) => { const i18n = new I18n(); promptCaptchaPage({ + verifyChallengeChars: register, + requestChallenge: () => createChallenge(), cancel: () => resolve(cancel), - focus: true, - verifyChallengeChars: async ({ chars, challenge }) => { - const tempIdentity = await ECDSAKeyIdentity.generate({ - extractable: false, - }); - const result = await connection.register({ - identity, - tempIdentity, - alias, - challengeResult: { - key: challenge.challenge_key, - chars, - }, - }); - - switch (result.kind) { - case "badChallenge": - return badChallenge; - default: - return result; - } - }, - - requestChallenge: precomputedFirst( - // For the first call, use a pre-generated challenge - // if available. - challenge ?? connection.createChallenge(), - () => connection.createChallenge() - ), - onContinue: resolve, i18n, scrollToTop: true, + focus: true, }); }); }; // Returns a function that returns `first` on the first call, // and values returned by `f()` from the second call on. -function precomputedFirst(first: T, f: () => T): () => T { +export function precomputeFirst(f: () => T): () => T { let firstTime = true; + const first: T = f(); return () => { if (firstTime) { diff --git a/src/frontend/src/flows/register/index.ts b/src/frontend/src/flows/register/index.ts index 13457523d7..81b53afc68 100644 --- a/src/frontend/src/flows/register/index.ts +++ b/src/frontend/src/flows/register/index.ts @@ -1,60 +1,124 @@ +import { Challenge } from "$generated/internet_identity_types"; import { + LoginFlowCanceled, + LoginFlowResult, apiResultToLoginFlowResult, cancel, - LoginFlowResult, } from "$src/utils/flowResult"; -import { Connection, IIWebAuthnIdentity } from "$src/utils/iiConnection"; +import { + AuthenticatedConnection, + Connection, + IIWebAuthnIdentity, + RegisterResult, +} from "$src/utils/iiConnection"; import { setAnchorUsed } from "$src/utils/userNumber"; import { unknownToString } from "$src/utils/utils"; +import { ECDSAKeyIdentity } from "@dfinity/identity"; import { nonNullish } from "@dfinity/utils"; import type { UAParser } from "ua-parser-js"; -import { promptCaptcha } from "./captcha"; +import { badChallenge, precomputeFirst, promptCaptcha } from "./captcha"; import { displayUserNumberWarmup } from "./finish"; import { savePasskey } from "./passkey"; /** Registration (anchor creation) flow for new users */ +export const registerFlow = async ({ + createChallenge: createChallenge_, + register, +}: { + createChallenge: () => Promise; + register: (opts: { + alias: string; + identity: IIWebAuthnIdentity; + challengeResult: { chars: string; challenge: Challenge }; + }) => Promise>; +}): Promise | LoginFlowCanceled> => { + // 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 + const createChallenge = precomputeFirst(() => createChallenge_()); + + const identity = await savePasskey(); + if (identity === "canceled") { + return cancel; + } + + const alias = await inferAlias({ + authenticatorType: identity.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); + + const displayUserNumber = displayUserNumberWarmup(); + + const result = await promptCaptcha({ + createChallenge, + register: async ({ chars, challenge }) => { + const result = await register({ + identity, + alias, + challengeResult: { chars, challenge }, + }); + + if (result.kind === "badChallenge") { + return badChallenge; + } + + return result; + }, + }); + + if ("tag" in result) { + result.tag satisfies "canceled"; + return result; + } + + if (result.kind === "loginSuccess") { + const userNumber = result.userNumber; + setAnchorUsed(userNumber); + await displayUserNumber({ userNumber }); + } + return result; +}; + +/** Concrete implementation of the registration flow */ export const register = async ({ connection, }: { connection: Connection; }): Promise => { try { - // 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 - const preloadedChallenge = connection.createChallenge(); - const identity = await savePasskey(); - if (identity === "canceled") { - return cancel; - } - - const alias = await inferAlias({ - authenticatorType: identity.getAuthenticatorAttachment(), - userAgent: navigator.userAgent, - uaParser, + const result = await registerFlow({ + createChallenge: () => connection.createChallenge(), + register: async ({ + identity, + alias, + challengeResult: { + chars, + challenge: { challenge_key: key }, + }, + }) => { + const tempIdentity = await ECDSAKeyIdentity.generate({ + extractable: false, + }); + const result = await connection.register({ + identity, + tempIdentity, + alias, + challengeResult: { chars, key }, + }); + + return result; + }, }); - const displayUserNumber = displayUserNumberWarmup(); - const captchaResult = await promptCaptcha({ - connection, - challenge: preloadedChallenge, - identity, - alias, - }); - - if ("tag" in captchaResult) { - return captchaResult; - } else { - const result = apiResultToLoginFlowResult(captchaResult); - if (result.tag === "ok") { - const { userNumber } = result; - setAnchorUsed(userNumber); - await displayUserNumber({ userNumber }); - } + if ("tag" in result) { + result satisfies { tag: "canceled" }; return result; } + + return apiResultToLoginFlowResult(result); } catch (e) { return { tag: "err", diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts index fa20462d1c..61433abd7a 100644 --- a/src/frontend/src/utils/iiConnection.ts +++ b/src/frontend/src/utils/iiConnection.ts @@ -83,17 +83,17 @@ export type LoginResult = | NoSeedPhrase | SeedPhraseFail | CancelOrTimeout; -export type RegisterResult = - | LoginSuccess +export type RegisterResult = + | LoginSuccess | AuthFail | ApiError | RegisterNoSpace | BadChallenge | CancelOrTimeout; -type LoginSuccess = { +type LoginSuccess = { kind: "loginSuccess"; - connection: AuthenticatedConnection; + connection: T; userNumber: bigint; }; diff --git a/src/showcase/src/flows.ts b/src/showcase/src/flows.ts index 8886cf8c10..186413abd3 100644 --- a/src/showcase/src/flows.ts +++ b/src/showcase/src/flows.ts @@ -1,3 +1,5 @@ +import { toast } from "$src/components/toast"; +import { registerFlow } from "$src/flows/register"; import { badChallenge, promptCaptchaPage } from "$src/flows/register/captcha"; import { TemplateResult, html, render } from "lit-html"; import { dummyChallenge, i18n } from "./showcase"; @@ -25,6 +27,40 @@ export const iiFlows: Record void> = { focus: false, }); }, + + register: async () => { + const result = await registerFlow({ + createChallenge: async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return dummyChallenge; + }, + register: async ({ challengeResult: { chars } }) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + + if (chars !== "8wJ6Q") { + return { kind: "badChallenge" }; + } + return { + kind: "loginSuccess", + userNumber: BigInt(12356), + connection: null, + }; + }, + }); + + toast.success(html` + Identity successfully created!
+

+ ${prettyResult(result)} +

+ + `); + }, }; const pageContent: TemplateResult = html` @@ -49,3 +85,19 @@ const pageContent: TemplateResult = html` `; + +const prettyResult = (obj: unknown) => { + if (typeof obj === "string") { + return obj; + } + + if (typeof obj === "object" && obj !== null) { + return html`
    + ${Object.entries(obj).map( + ([k, v]) => html`
  • ${k}: ${v}
  • ` + )} +
`; + } + + return JSON.stringify(obj); +};