Skip to content

Commit 6a27871

Browse files
author
Llorenç Muntaner
authored
Fetch identity credentials (dfinity#3223)
* Fetch identity credentials * Refactor * Use anonymous actor * Add E2E test * Move fetching identities to store * Remove unnecessary comment * Refactor * Revert unnecessary field * Add loading state * Store the promise of credentials * Prepare auth before clicking
1 parent bc09b9d commit 6a27871

7 files changed

Lines changed: 121 additions & 7 deletions

File tree

src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<script lang="ts">
22
import { nonNullish } from "@dfinity/utils";
33
import { AuthFlow } from "$lib/flows/authFlow.svelte";
4-
import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte";
54
import type { Snippet } from "svelte";
65
import SolveCaptcha from "$lib/components/wizards/auth/views/SolveCaptcha.svelte";
76
import PickAuthenticationMethod from "$lib/components/wizards/auth/views/PickAuthenticationMethod.svelte";
@@ -32,7 +31,6 @@
3231
}: Props = $props();
3332
3433
const authFlow = new AuthFlow();
35-
const authLastUsedFlow = new AuthLastUsedFlow();
3634
3735
let isContinueFromAnotherDeviceVisible = $state(false);
3836
@@ -109,6 +107,6 @@
109107
{/if}
110108
{/if}
111109

112-
{#if authFlow.systemOverlay || authLastUsedFlow.systemOverlay}
110+
{#if authFlow.systemOverlay}
113111
<SystemOverlayBackdrop />
114112
{/if}

src/frontend/src/lib/flows/authLastUsedFlow.svelte.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,32 @@ import { createGoogleRequestConfig, requestJWT } from "$lib/utils/openID";
1212
import { get } from "svelte/store";
1313
import { sessionStore } from "$lib/stores/session.store";
1414
import { isNullish } from "@dfinity/utils";
15+
import { fetchIdentityCredentials } from "$lib/utils/fetchCredentials";
1516

1617
export class AuthLastUsedFlow {
1718
systemOverlay = $state(false);
1819
authenticatingIdentity = $state<bigint | null>(null);
20+
#identityCredentials: Map<bigint, Promise<Uint8Array[] | undefined>> =
21+
new Map();
22+
init(identities: bigint[]) {
23+
identities.forEach((identityNumber) => {
24+
this.#identityCredentials.set(
25+
identityNumber,
26+
fetchIdentityCredentials(identityNumber),
27+
);
28+
});
29+
}
1930
authenticate = async (lastUsedIdentity: LastUsedIdentity): Promise<void> => {
2031
this.authenticatingIdentity = lastUsedIdentity.identityNumber;
2132
try {
2233
if ("passkey" in lastUsedIdentity.authMethod) {
34+
const credentialIds = (await this.#identityCredentials.get(
35+
lastUsedIdentity.identityNumber,
36+
)) ?? [lastUsedIdentity.authMethod.passkey.credentialId];
2337
const { identity, identityNumber } = await authenticateWithPasskey({
2438
canisterId,
2539
session: get(sessionStore),
26-
credentialIds: [lastUsedIdentity.authMethod.passkey.credentialId],
40+
credentialIds,
2741
});
2842
authenticationStore.set({ identity, identityNumber });
2943
lastUsedIdentitiesStore.addLastUsedIdentity(lastUsedIdentity);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { anonymousActor } from "$lib/globals";
2+
import { nonNullish } from "@dfinity/utils";
3+
import { convertToValidCredentialData } from "./credential-devices";
4+
5+
/**
6+
* Fetches the credentials for a given identity number.
7+
* @param identityNumber The identity number to fetch credentials for.
8+
* @returns An array of credentials or undefined if no valid credentials are found.
9+
*/
10+
export const fetchIdentityCredentials = async (
11+
identityNumber: bigint,
12+
): Promise<Uint8Array[] | undefined> => {
13+
try {
14+
const identityCredentials = await anonymousActor.lookup(identityNumber);
15+
const validCredentials = identityCredentials
16+
.filter((device) => "authentication" in device.purpose)
17+
.filter(({ key_type }) => !("browser_storage_key" in key_type))
18+
.map(convertToValidCredentialData)
19+
.filter(nonNullish);
20+
21+
if (validCredentials.length > 0) {
22+
return validCredentials.map(
23+
(credential) => new Uint8Array(credential.credentialId),
24+
);
25+
}
26+
27+
return undefined;
28+
} catch (error) {
29+
console.warn(
30+
`Error looking up identity ${identityNumber} credentials:`,
31+
error,
32+
);
33+
return undefined;
34+
}
35+
};

src/frontend/src/routes/(new-styling)/+page.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,19 @@
5858
gotoNext();
5959
}
6060
};
61-
const authLastUsedFlow = new AuthLastUsedFlow();
6261
6362
const lastUsedIdentities = $derived(
6463
Object.values($lastUsedIdentitiesStore.identities)
6564
.sort((a, b) => b.lastUsedTimestampMillis - a.lastUsedTimestampMillis)
6665
.slice(0, 3),
6766
);
67+
const authLastUsedFlow = new AuthLastUsedFlow();
68+
// Initialize the flow every time the last used identities change
69+
$effect(() =>
70+
authLastUsedFlow.init(
71+
lastUsedIdentities.map(({ identityNumber }) => identityNumber),
72+
),
73+
);
6874
6975
let isAuthDialogOpen = $state(false);
7076
let isAuthenticating = $state(false);

src/frontend/src/routes/(new-styling)/authorize/continue/+page.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
return accounts;
5757
});
5858
const authLastUsedFlow = new AuthLastUsedFlow();
59+
// Initialize the flow every time the last used identities change
60+
$effect(() =>
61+
authLastUsedFlow.init(
62+
lastUsedAccounts.map(({ identityNumber }) => identityNumber),
63+
),
64+
);
5965
let loading = $state(false);
6066
6167
const handleContinueAs = async (account: LastUsedAccount) => {

src/frontend/src/routes/(new-styling)/manage/(authenticated)/+layout.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@
6161
isAuthDialogOpen = false;
6262
};
6363
64+
const authLastUsedFlow = new AuthLastUsedFlow();
65+
$effect(() =>
66+
authLastUsedFlow.init(
67+
lastUsedIdentities.map(({ identityNumber }) => identityNumber),
68+
),
69+
);
70+
6471
const handleSwitchIdentity = async (identityNumber: bigint) => {
65-
const authLastUsedFlow = new AuthLastUsedFlow();
6672
const chosenIdentity =
6773
$lastUsedIdentitiesStore.identities[Number(identityNumber)];
6874
await authLastUsedFlow.authenticate(chosenIdentity);

src/frontend/tests/e2e-playwright/dashboard/addPasskeys.spec.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111

1212
const TEST_USER_NAME = "Test User";
1313

14-
test("User can log into the dashboard and add a new passkey from the same device and log in with it", async ({
14+
test("User can log into the dashboard and add a new passkey from the same device and log in with it after clearing storage", async ({
1515
page,
1616
context,
1717
}) => {
@@ -144,3 +144,52 @@ test("User can log in the dashboard and add a new passkey from another device",
144144
// Verify that we now have two passkeys
145145
await expect(page.getByText("Chrome")).toHaveCount(2);
146146
});
147+
148+
test("User can add a new passkey and use it with cached identity without clearing storage", async ({
149+
page,
150+
context,
151+
}) => {
152+
const auth = dummyAuth();
153+
await page.goto(II_URL);
154+
await createNewIdentityInII(page, TEST_USER_NAME, auth);
155+
await page.waitForURL(II_URL + "/manage");
156+
157+
// Verify we're at the dashboard and have one passkey
158+
await expect(page.getByText("Chrome")).toHaveCount(1);
159+
160+
// Start the "add passkey" flow
161+
const auth2 = dummyAuth();
162+
await addPasskeyCurrentDevice(page, auth2);
163+
await expect(page.getByText("Chrome")).toHaveCount(2);
164+
await renamePasskey(page, "New Passkey");
165+
166+
// Verify that the new passkey is not the current one
167+
await expect(
168+
page.getByText("Chrome").locator("..").getByLabel("Current Passkey"),
169+
).toHaveCount(1);
170+
await expect(
171+
page.getByText("New Passkey").locator("..").getByLabel("Current Passkey"),
172+
).toHaveCount(0);
173+
174+
await signOut(page);
175+
176+
// Log in again with new passkey WITHOUT clearing storage (key difference)
177+
// This should use the cached identity
178+
const newPage = await context.newPage();
179+
await newPage.goto(II_URL);
180+
181+
// Click on the cached identity button directly
182+
// But use the new passkey to authenticate
183+
auth2(newPage);
184+
await newPage.getByRole("button", { name: TEST_USER_NAME }).click();
185+
186+
// Verify we're logged in with the new passkey
187+
await newPage.waitForURL(II_URL + "/manage");
188+
await expect(
189+
newPage.getByRole("heading", {
190+
name: new RegExp(`Welcome, ${TEST_USER_NAME}!`),
191+
}),
192+
).toBeVisible();
193+
194+
await newPage.close();
195+
});

0 commit comments

Comments
 (0)