diff --git a/docs/ii-spec.mdx b/docs/ii-spec.mdx
index dd212bd432..f3005776ec 100644
--- a/docs/ii-spec.mdx
+++ b/docs/ii-spec.mdx
@@ -173,7 +173,7 @@ 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 validate the `derivationOrigin` by checking that it lists the client application origin in the `/.well-known/ii-alternative-origins` file (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.
+ - 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 and the corresponding identity has been the most recently used for the client application origin, 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`.
diff --git a/src/frontend/src/components/authenticateBox/index.ts b/src/frontend/src/components/authenticateBox/index.ts
index 3c35bf8675..d6641451c3 100644
--- a/src/frontend/src/components/authenticateBox/index.ts
+++ b/src/frontend/src/components/authenticateBox/index.ts
@@ -431,7 +431,11 @@ export const authnTemplates = (i18n: I18n, props: AuthnTemplates) => {
-
diff --git a/src/frontend/src/flows/authorize/index.ts b/src/frontend/src/flows/authorize/index.ts
index 77b5607e51..0b1b46c51e 100644
--- a/src/frontend/src/flows/authorize/index.ts
+++ b/src/frontend/src/flows/authorize/index.ts
@@ -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 { getAnchorByPrincipal, setKnownPrincipal } from "$src/storage";
+import { getAnchorIfLastUsed, setKnownPrincipal } from "$src/storage";
import { Connection } from "$src/utils/iiConnection";
import { TemplateElement } from "$src/utils/lit-html";
import { Chan } from "$src/utils/utils";
@@ -189,8 +189,9 @@ const authenticate = async (
let autoSelectionIdentity = undefined;
if (nonNullish(authContext.authRequest.autoSelectionPrincipal)) {
- autoSelectionIdentity = await getAnchorByPrincipal({
+ autoSelectionIdentity = await getAnchorIfLastUsed({
principal: authContext.authRequest.autoSelectionPrincipal,
+ origin: authContext.requestOrigin,
});
}
diff --git a/src/frontend/src/storage/index.test.ts b/src/frontend/src/storage/index.test.ts
index 95f2e27d9b..475d3cc80f 100644
--- a/src/frontend/src/storage/index.test.ts
+++ b/src/frontend/src/storage/index.test.ts
@@ -12,6 +12,7 @@ import {
MAX_SAVED_ANCHORS,
MAX_SAVED_PRINCIPALS,
getAnchorByPrincipal,
+ getAnchorIfLastUsed,
getAnchors,
setAnchorUsed,
setKnownPrincipal,
@@ -311,6 +312,108 @@ test(
vi.useRealTimers();
})
);
+
+test(
+ "should retrieve last anchor",
+ withStorage(async () => {
+ const origin = "https://example.com";
+ const principal = Principal.fromText("2vxsx-fae");
+ await setKnownPrincipal({
+ userNumber: BigInt(10000),
+ origin,
+ principal,
+ });
+
+ expect(await getAnchorIfLastUsed({ principal, origin })).toBe(
+ BigInt(10000)
+ );
+ })
+);
+
+test(
+ "should not retrieve anchor if not most recent",
+ withStorage(async () => {
+ const origin = "https://example.com";
+ const oldPrincipal = Principal.fromText(
+ "hawxh-fq2bo-p5sh7-mmgol-l3vtr-f72w2-q335t-dcbni-2n25p-xhusp-fqe"
+ );
+ vi.useFakeTimers().setSystemTime(new Date(0));
+ const oldIdentity = BigInt(10000);
+ await setKnownPrincipal({
+ userNumber: oldIdentity,
+ origin,
+ principal: oldPrincipal,
+ });
+
+ vi.useFakeTimers().setSystemTime(new Date(1));
+ const mostRecentPrincipal = Principal.fromText(
+ "lrf2i-zba54-pygwt-tbi75-zvlz4-7gfhh-ylcrq-2zh73-6brgn-45jy5-cae"
+ );
+ const mostRecentIdentity = BigInt(10001);
+ await setKnownPrincipal({
+ userNumber: mostRecentIdentity,
+ origin,
+ principal: mostRecentPrincipal,
+ });
+
+ expect(
+ await getAnchorIfLastUsed({ principal: oldPrincipal, origin })
+ ).not.toBeDefined();
+ // most recent principal still works
+ expect(
+ await getAnchorIfLastUsed({ principal: mostRecentPrincipal, origin })
+ ).toBe(mostRecentIdentity);
+ })
+);
+
+test(
+ "latest principals on different origins can be retrieved",
+ withStorage(async () => {
+ const origin1 = "https://example1.com";
+ const origin2 = "https://example2.com";
+ const principal1 = Principal.fromText(
+ "hawxh-fq2bo-p5sh7-mmgol-l3vtr-f72w2-q335t-dcbni-2n25p-xhusp-fqe"
+ );
+ vi.useFakeTimers().setSystemTime(new Date(0));
+ const identity1 = BigInt(10000);
+ await setKnownPrincipal({
+ userNumber: identity1,
+ origin: origin1,
+ principal: principal1,
+ });
+
+ vi.useFakeTimers().setSystemTime(new Date(1));
+ const principal2 = Principal.fromText(
+ "lrf2i-zba54-pygwt-tbi75-zvlz4-7gfhh-ylcrq-2zh73-6brgn-45jy5-cae"
+ );
+ const identity2 = BigInt(10001);
+ await setKnownPrincipal({
+ userNumber: identity2,
+ origin: origin2,
+ principal: principal2,
+ });
+
+ expect(
+ await getAnchorIfLastUsed({ principal: principal1, origin: origin1 })
+ ).toBe(identity1);
+ expect(
+ await getAnchorIfLastUsed({ principal: principal2, origin: origin2 })
+ ).toBe(identity2);
+ })
+);
+
+test(
+ "should not retrieve unknown principal",
+ withStorage(async () => {
+ const origin = "https://example.com";
+ const principal = Principal.fromText(
+ "hawxh-fq2bo-p5sh7-mmgol-l3vtr-f72w2-q335t-dcbni-2n25p-xhusp-fqe"
+ );
+
+ expect(await getAnchorIfLastUsed({ principal, origin })).not.toBeDefined();
+ })
+);
+
/** Test storage usage. Storage is cleared after the callback has returned.
* If `before` is specified, storage is populated with its content before the test is run.
* If `after` is specified, the content of storage are checked against `after` after the
diff --git a/src/frontend/src/storage/index.ts b/src/frontend/src/storage/index.ts
index 63d70e9939..071fd2914c 100644
--- a/src/frontend/src/storage/index.ts
+++ b/src/frontend/src/storage/index.ts
@@ -81,6 +81,60 @@ export const getAnchorByPrincipal = async ({
return;
};
+/** Look up an anchor by principal, if it is the last used for the given origin.
+ */
+export const getAnchorIfLastUsed = async ({
+ principal,
+ origin,
+}: {
+ principal: Principal;
+ origin: string;
+}): Promise => {
+ const storage = await readStorage();
+ const anchors = storage.anchors;
+
+ const principalDigest = await computePrincipalDigest({
+ principal,
+ hasher: storage.hasher,
+ });
+
+ const originDigest = await computeOriginDigest({
+ origin,
+ hasher: storage.hasher,
+ });
+
+ // candidate anchors with their timestamp, principal digest -> identity number
+ const candidates = [];
+ for (const ix in anchors) {
+ const anchor: Anchor = anchors[ix];
+
+ // there is at most one principal known per anchor and origin.
+ const lastUsed = anchor.knownPrincipals.filter(
+ (knownPrincipal) => knownPrincipal.originDigest === originDigest
+ )[0];
+ if (isNullish(lastUsed)) {
+ continue;
+ }
+ candidates.push({
+ lastUsedTimestamp: lastUsed.lastUsedTimestamp,
+ principalDigest: lastUsed.principalDigest,
+ identityNumber: ix,
+ });
+ }
+
+ candidates.sort((a, b) => b.lastUsedTimestamp - a.lastUsedTimestamp);
+ const mostRecent = candidates[0];
+ if (isNullish(mostRecent)) {
+ return;
+ }
+
+ if (mostRecent.principalDigest === principalDigest) {
+ return BigInt(mostRecent.identityNumber);
+ }
+
+ return;
+};
+
/** Set the principal as "known"; i.e. from which the anchor can be "looked up" */
export const setKnownPrincipal = async ({
userNumber,
diff --git a/src/frontend/src/test-e2e/knownPrincipal.test.ts b/src/frontend/src/test-e2e/knownPrincipal.test.ts
index fdb763129c..c70355e1e5 100644
--- a/src/frontend/src/test-e2e/knownPrincipal.test.ts
+++ b/src/frontend/src/test-e2e/knownPrincipal.test.ts
@@ -75,6 +75,43 @@ test("Should require user interaction when supplying unknown auto-select princip
});
}, 300_000);
+test("Should require user interaction when supplying not most recent auto-select principal", async () => {
+ await runInBrowser(async (browser: WebdriverIO.Browser) => {
+ const { demoAppView, credentials, userNumber } = await registerAndSignIn(
+ browser
+ );
+ const principal = await demoAppView.waitForAuthenticated();
+
+ // register a second identity
+ await registerAndSignIn(browser);
+ const principal2 = await demoAppView.waitForAuthenticated();
+ expect(principal).not.toBe(principal2);
+
+ // authenticate again, but supply the first (older) known principal
+ await demoAppView.setAutoSelectionPrincipal(principal);
+ 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
+ const principal3 = await demoAppView.waitForAuthenticated();
+ // We are signed in as the first user again.
+ expect(principal3).toBe(principal);
+ });
+}, 300_000);
+
/**
* Registers a user and signs in with the demo app.
* @param browser browser to use.
diff --git a/src/frontend/src/test-e2e/views.ts b/src/frontend/src/test-e2e/views.ts
index 2f41d08886..0bf8fd8538 100644
--- a/src/frontend/src/test-e2e/views.ts
+++ b/src/frontend/src/test-e2e/views.ts
@@ -527,6 +527,10 @@ export class AuthenticateView extends View {
}
async register(): Promise {
+ const moreOptions = await this.browser.$('[data-role="more-options"]');
+ if (await moreOptions.isExisting()) {
+ await moreOptions.click();
+ }
await this.browser.$("#registerButton").click();
}