Skip to content

Commit

Permalink
Only allow the most recently used identity to be auto-selected (#2579)
Browse files Browse the repository at this point in the history
* Only allow the most recently used identity to be auto-selected

This is an enhancement to the auto-selection feature introduced in #2563:
In order to not confuse users, only the most recently used identity can
be auto-selected (i.e. when refreshing sessions). This way, a dapp cannot
make a user _switch_ identities without them having the identity selected
explicitly.

* Address review input
  • Loading branch information
Frederik Rothenberger authored Aug 30, 2024
1 parent 4bae280 commit fd5486a
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 4 deletions.
2 changes: 1 addition & 1 deletion docs/ii-spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 5 additions & 1 deletion src/frontend/src/components/authenticateBox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,11 @@ export const authnTemplates = (i18n: I18n, props: AuthnTemplates) => {
<ul class="c-link-group">
<li>
<button @click=${() => useExistingProps.register()} class="t-link">
<button
@click=${() => useExistingProps.register()}
id="registerButton"
class="t-link"
>
Create New
</button>
</li>
Expand Down
5 changes: 3 additions & 2 deletions 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 { 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";
Expand Down Expand Up @@ -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,
});
}

Expand Down
103 changes: 103 additions & 0 deletions src/frontend/src/storage/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MAX_SAVED_ANCHORS,
MAX_SAVED_PRINCIPALS,
getAnchorByPrincipal,
getAnchorIfLastUsed,
getAnchors,
setAnchorUsed,
setKnownPrincipal,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/frontend/src/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<bigint | undefined> => {
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,
Expand Down
37 changes: 37 additions & 0 deletions src/frontend/src/test-e2e/knownPrincipal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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 @@ -527,6 +527,10 @@ export class AuthenticateView extends View {
}

async register(): Promise<void> {
const moreOptions = await this.browser.$('[data-role="more-options"]');
if (await moreOptions.isExisting()) {
await moreOptions.click();
}
await this.browser.$("#registerButton").click();
}

Expand Down

0 comments on commit fd5486a

Please sign in to comment.