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

Implement API to auto-select identity based on known principal #2563

Merged
merged 3 commits into from
Aug 26, 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
3 changes: 3 additions & 0 deletions demos/test-app/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ export const authWithII = async ({
allowPinAuthentication,
derivationOrigin,
sessionIdentity,
autoSelectionPrincipal,
}: {
url: string;
maxTimeToLive?: bigint;
allowPinAuthentication?: boolean;
derivationOrigin?: string;
autoSelectionPrincipal?: string;
sessionIdentity: SignIdentity;
}): Promise<{ identity: DelegationIdentity; authnMethod: string }> => {
// Figure out the II URL to use
Expand Down Expand Up @@ -74,6 +76,7 @@ export const authWithII = async ({
maxTimeToLive,
derivationOrigin,
allowPinAuthentication,
autoSelectionPrincipal,
};

win.postMessage(request, iiUrl.origin);
Expand Down
8 changes: 8 additions & 0 deletions demos/test-app/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ <h2>Sign In</h2>
placeholder="(use default)"
/>
</div>
<div>
<label
for="autoSelectionPrincipal"
style="display: inline-block; width: 200px"
>Auto-select identity for known principal:</label
>
<input type="text" id="autoSelectionPrincipal" placeholder="(none)" />
</div>
<div>
<label
for="allowPinAuthentication"
Expand Down
8 changes: 8 additions & 0 deletions demos/test-app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ const maxTimeToLiveEl = document.getElementById(
const derivationOriginEl = document.getElementById(
"derivationOrigin"
) as HTMLInputElement;
const autoSelectionPrincipalEl = document.getElementById(
"autoSelectionPrincipal"
) as HTMLInputElement;
const allowPinAuthenticationEl = document.getElementById(
"allowPinAuthentication"
) as HTMLInputElement;
Expand Down Expand Up @@ -222,6 +225,10 @@ const init = async () => {
maxTimeToLive_ > BigInt(0) ? maxTimeToLive_ : authClientDefaultMaxTTL;
const derivationOrigin =
derivationOriginEl.value !== "" ? derivationOriginEl.value : undefined;
const autoSelectionPrincipal =
autoSelectionPrincipalEl.value !== ""
? autoSelectionPrincipalEl.value
: undefined;

const allowPinAuthentication = allowPinAuthenticationEl.checked
? undefined
Expand All @@ -234,6 +241,7 @@ const init = async () => {
derivationOrigin,
allowPinAuthentication,
sessionIdentity: getLocalIdentity(),
autoSelectionPrincipal,
});
delegationIdentity = result.identity;
updateDelegationView({
Expand Down
3 changes: 3 additions & 0 deletions docs/ii-spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ This section describes the Internet Identity Service from the point of view of a
maxTimeToLive?: bigint;
allowPinAuthentication?: boolean;
derivationOrigin?: string;
autoSelectionPrincipal?: string
}
```

Expand All @@ -172,6 +173,8 @@ This section describes the Internet Identity Service from the point of view of a

- the `derivationOrigin`, if present, indicates an origin that should be used for principal derivation instead of the client origin. Internet Identity will only accept values that are also listed in the HTTP resource `/.well-known/ii-alternative-origins` of the corresponding canister (see [Alternative Frontend Origins](#alternative-frontend-origins)).

- the `autoSelectionPrincipal`, if present, indicates the textual representation of this dapp's principal for which the delegation is requested. If it is known to Internet Identity, it will skip the identity selection and immediately prompt for authentication. This feature can be used to streamline re-authentication after a session expiry.


6. Now the client application window expects a message back, with data `event`.

Expand Down
38 changes: 31 additions & 7 deletions src/frontend/src/components/authenticateBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,20 @@ export const authenticateBox = async ({
i18n,
templates,
allowPinAuthentication,
autoSelectionIdentity,
}: {
connection: Connection;
i18n: I18n;
templates: AuthnTemplates;
allowPinAuthentication: boolean;
autoSelectionIdentity?: bigint;
}): Promise<{
userNumber: bigint;
connection: AuthenticatedConnection;
newAnchor: boolean;
authnMethod: "pin" | "passkey" | "recovery";
}> => {
const promptAuth = () =>
const promptAuth = (autoSelectIdentity?: bigint) =>
authenticateBoxFlow<PinIdentityMaterial>({
i18n,
templates,
Expand All @@ -99,12 +101,13 @@ export const authenticateBox = async ({
retrievePinIdentityMaterial: ({ userNumber }) =>
idbRetrievePinIdentityMaterial({ userNumber }),
allowPinAuthentication,
autoSelectIdentity,
});

// Retry until user has successfully authenticated
for (;;) {
try {
const result = await promptAuth();
const result = await promptAuth(autoSelectionIdentity);

// If the user canceled or just added a device, we retry
if ("tag" in result) {
Expand All @@ -126,6 +129,9 @@ export const authenticateBox = async ({
primaryButton: "Try again",
});
}
// clear out the auto-select so that after the first error / cancel
// the identity number picker actually waits for input
autoSelectionIdentity = undefined;
}
};

Expand Down Expand Up @@ -165,6 +171,7 @@ export const authenticateBoxFlow = async <I>({
verifyPinValidity,
retrievePinIdentityMaterial,
allowPinAuthentication,
autoSelectIdentity,
}: {
i18n: I18n;
templates: AuthnTemplates;
Expand Down Expand Up @@ -192,6 +199,7 @@ export const authenticateBoxFlow = async <I>({
userNumber: bigint;
}) => Promise<I | undefined>;
allowPinAuthentication: boolean;
autoSelectIdentity?: bigint;
verifyPinValidity: (opts: {
userNumber: bigint;
pinIdentityMaterial: I;
Expand Down Expand Up @@ -292,7 +300,10 @@ export const authenticateBoxFlow = async <I>({
// we assume a new user and show the "firstTime" screen.
const anchors = await getAnchors();
if (isNonEmptyArray(anchors)) {
const result = await pages.pick({ anchors });
const result = await pages.pick({
anchors,
autoSelect: autoSelectIdentity,
});

if (result.tag === "pick") {
return doLogin({ userNumber: result.userNumber });
Expand Down Expand Up @@ -565,16 +576,29 @@ export const authnScreens = (i18n: I18n, props: AuthnTemplates) => {
resolve({ tag: "recover", userNumber }),
})
),
pick: (pickProps: { anchors: NonEmptyArray<bigint> }) =>
pick: (pickProps: {
anchors: NonEmptyArray<bigint>;
autoSelect?: bigint;
Copy link
Collaborator

Choose a reason for hiding this comment

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

autoSelect seems like a boolean type of parameter. How about selectedIdentity?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed, it is only automatically selected on the initial picker screen, but the user can still override that by cancelling the passkey interaction and picking something else.

}) =>
new Promise<
{ tag: "more_options" } | { tag: "pick"; userNumber: bigint }
>((resolve) =>
>((resolve) => {
// render page first so that when the identity is picked and the passkey
Copy link
Collaborator

Choose a reason for hiding this comment

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

What is it that we need to wait? Couldn't we show a spinner while we wait for it and then resolve?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, normally we wait for the user to chose. But if we have an a autoSelect identity, then the action completes immediately. I am just threading it through here, because the identity picker should still be rendered, before automatically selecting something. Otherwise you have the passkey interaction from a completely blank page, which is pretty weird. This way, you have at least a (albeit blurred out) version of the II page in the background.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We could render something else if we want to improve it I guess. But definitely not a priority.

// dialog pops up, the II page is not just blank.
pages.pick({
...pickProps,
onSubmit: (userNumber) => resolve({ tag: "pick", userNumber }),
moreOptions: () => resolve({ tag: "more_options" }),
})
),
});
// If an existing autoSelect value is supplied immediately
// resolve with the auto-selected identity number
if (
nonNullish(pickProps.autoSelect) &&
pickProps.anchors.includes(pickProps.autoSelect)
) {
resolve({ tag: "pick", userNumber: pickProps.autoSelect });
}
}),
};
};

Expand Down
11 changes: 10 additions & 1 deletion src/frontend/src/flows/authorize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { showSpinner } from "$src/components/spinner";
import { getDapps } from "$src/flows/dappsExplorer/dapps";
import { recoveryWizard } from "$src/flows/recovery/recoveryWizard";
import { I18n } from "$src/i18n";
import { setKnownPrincipal } from "$src/storage";
import { getAnchorByPrincipal, setKnownPrincipal } from "$src/storage";
import { Connection } from "$src/utils/iiConnection";
import { TemplateElement } from "$src/utils/lit-html";
import { Chan } from "$src/utils/utils";
Expand Down Expand Up @@ -188,6 +188,14 @@ const authenticate = async (
};
}

let autoSelectionIdentity = undefined;
if (nonNullish(authContext.authRequest.autoSelectionPrincipal)) {
autoSelectionIdentity = await getAnchorByPrincipal({
origin: authContext.requestOrigin,
principal: authContext.authRequest.autoSelectionPrincipal,
});
}

const authSuccess = await authenticateBox({
connection,
i18n,
Expand All @@ -201,6 +209,7 @@ const authenticate = async (
}),
allowPinAuthentication:
authContext.authRequest.allowPinAuthentication ?? true,
autoSelectionIdentity: autoSelectionIdentity,
});

// Here, if the user is returning & doesn't have any recovery device, we prompt them to add
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/src/flows/authorize/postMessageInterface.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Types and functions related to the window post message interface used by
// applications that want to authenticate the user using Internet Identity
import { Principal } from "@dfinity/principal";
import { z } from "zod";
import { Delegation } from "./fetchDelegation";

Expand All @@ -23,6 +24,17 @@ export interface AuthContext {
requestOrigin: string;
}

const zodPrincipal = z.string().transform((val, ctx) => {
let principal;
try {
principal = Principal.fromText(val);
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a principal " });
return z.NEVER;
}
return principal;
});

export const AuthRequest = z.object({
kind: z.literal("authorize-client"),
sessionPublicKey: z.instanceof(Uint8Array),
Expand All @@ -41,6 +53,7 @@ export const AuthRequest = z.object({
}),
derivationOrigin: z.optional(z.string()),
allowPinAuthentication: z.optional(z.boolean()),
autoSelectionPrincipal: z.optional(zodPrincipal),
});

export type AuthRequest = z.output<typeof AuthRequest>;
Expand Down
97 changes: 97 additions & 0 deletions src/frontend/src/test-e2e/knownPrincipal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { II_URL, TEST_APP_NICE_URL } from "$src/test-e2e/constants";
import { FLOWS } from "$src/test-e2e/flows";
import {
addWebAuthnCredential,
getWebAuthnCredentials,
originToRelyingPartyId,
runInBrowser,
switchToPopup,
} from "$src/test-e2e/util";
import { AuthenticateView, DemoAppView } from "$src/test-e2e/views";
import { expect } from "vitest";

test("Should prompt for passkey auth immediately when supplying known auto-select principal", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const { demoAppView, credentials } = await registerAndSignIn(browser);
const exp1 = await browser.$("#expiration").getText();

// authenticate again, but this time _with_ a known principal
const knownPrincipal = await demoAppView.getPrincipal();
await demoAppView.setAutoSelectionPrincipal(knownPrincipal);
await demoAppView.signin();

// add credential previously registered to the new tab again
const authenticatorId2 = await switchToPopup(browser);
await addWebAuthnCredential(
browser,
authenticatorId2,
credentials[0],
originToRelyingPartyId(II_URL)
);

// Passkey interaction completes automatically with virtual authenticator
await demoAppView.waitForAuthenticated();

// By having different expiry timestamps we know that the sign-in completed
// twice successfully
const exp2 = await browser.$("#expiration").getText();
expect(exp1).not.toBe(exp2);
});
}, 300_000);

test("Should require user interaction when supplying unknown auto-select principal", async () => {
await runInBrowser(async (browser: WebdriverIO.Browser) => {
const { demoAppView, credentials, userNumber } = await registerAndSignIn(
browser
);
const exp1 = await browser.$("#expiration").getText();

// authenticate again, but this time with an unknown principal
// we use a canister id here, because II will never issue a canister id to users, but it is a valid principal
await demoAppView.setAutoSelectionPrincipal("rdmx6-jaaaa-aaaaa-aaadq-cai");
await demoAppView.signin();

// add credential previously registered to the new tab again
const authenticatorId2 = await switchToPopup(browser);
await addWebAuthnCredential(
browser,
authenticatorId2,
credentials[0],
originToRelyingPartyId(II_URL)
);

const authView = new AuthenticateView(browser);
await authView.waitForDisplay();
// needs explicit identity selection
await authView.pickAnchor(userNumber);

// Passkey interaction completes automatically with virtual authenticator
await demoAppView.waitForAuthenticated();

// By having different expiry timestamps we know that the sign-in completed
// twice successfully
const exp2 = await browser.$("#expiration").getText();
expect(exp1).not.toBe(exp2);
});
}, 300_000);

/**
* Registers a user and signs in with the demo app.
* @param browser browser to use.
* @return - the demo app view to proceed with the test
* - the identity number of the registered identity
* - the webauthn credential that was created when registering the identity.
*/
async function registerAndSignIn(browser: WebdriverIO.Browser) {
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();
const authenticatorId1 = await switchToPopup(browser);
const userNumber = await FLOWS.registerNewIdentityAuthenticateView(browser);
const credentials = await getWebAuthnCredentials(browser, authenticatorId1);
expect(credentials).toHaveLength(1);
await demoAppView.waitForAuthenticated();
return { demoAppView, credentials, userNumber };
}
4 changes: 4 additions & 0 deletions src/frontend/src/test-e2e/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,10 @@ export class DemoAppView extends View {
await fillText(this.browser, "derivationOrigin", derivationOrigin);
}

async setAutoSelectionPrincipal(principal: string): Promise<void> {
await fillText(this.browser, "autoSelectionPrincipal", principal);
}

async whoami(): Promise<string> {
await fillText(this.browser, "hostUrl", this.replicaUrl);
await this.browser.$("#whoamiBtn").click();
Expand Down
Loading