Skip to content

Commit a7b07f8

Browse files
Remove passkeys (#3182)
* Remove passkey * Remove passkey * Fix length check. * Fix lint * Update src/frontend/src/lib/components/views/RemovePasskeyDialog.svelte Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 40c1b96 commit a7b07f8

File tree

5 files changed

+193
-38
lines changed

5 files changed

+193
-38
lines changed

src/frontend/src/lib/components/views/RemoveOpenIdCredential.svelte

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,14 @@
33
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
44
import Button from "$lib/components/ui/Button.svelte";
55
import { TriangleAlertIcon } from "@lucide/svelte";
6-
import identityInfo from "$lib/stores/identity-info.state.svelte";
7-
import { handleError } from "../utils/error";
8-
import { type OpenIdCredential } from "$lib/generated/internet_identity_types";
96
10-
const {
11-
onClose,
12-
credentialToBeRemoved,
13-
}: { onClose: () => void; credentialToBeRemoved: OpenIdCredential } =
14-
$props();
7+
interface Props {
8+
onRemove: () => void;
9+
onClose: () => void;
10+
isCurrentAccessMethod?: boolean;
11+
}
1512
16-
const handleRemoveCredential = async () => {
17-
try {
18-
await identityInfo.removeGoogle();
19-
} catch (error) {
20-
handleError(error);
21-
}
22-
};
13+
const { onRemove, onClose, isCurrentAccessMethod }: Props = $props();
2314
</script>
2415

2516
<Dialog {onClose}>
@@ -31,14 +22,14 @@
3122
You're about to unlink your Google Account. If you proceed, you will no
3223
longer be able to sign-in to your identity or dapps using your Google
3324
Account.
34-
{#if identityInfo.isCurrentAccessMethod({ openid: credentialToBeRemoved })}
25+
{#if isCurrentAccessMethod}
3526
<br /><br />As you are currently signed in with this Account, you will be
3627
signed out.
3728
{/if}
3829
</p>
3930

4031
<div class="flex w-full flex-col gap-3">
41-
<Button onclick={handleRemoveCredential} variant="primary" danger>
32+
<Button onclick={onRemove} variant="primary" danger>
4233
Unlink Google Account
4334
</Button>
4435
<Button variant="tertiary" onclick={onClose}>Keep linked</Button>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script lang="ts">
2+
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
3+
import Button from "$lib/components/ui/Button.svelte";
4+
import { TriangleAlertIcon } from "@lucide/svelte";
5+
import Dialog from "$lib/components/ui/Dialog.svelte";
6+
7+
interface Props {
8+
onRemove: () => void;
9+
onClose: () => void;
10+
isCurrentAccessMethod?: boolean;
11+
}
12+
13+
const { onRemove, onClose, isCurrentAccessMethod = false }: Props = $props();
14+
</script>
15+
16+
<Dialog {onClose}>
17+
<FeaturedIcon variant="warning" size="lg" class="mb-3">
18+
<TriangleAlertIcon size="1.5rem" />
19+
</FeaturedIcon>
20+
<h1 class="text-text-primary mb-3 text-2xl font-medium">Are you sure?</h1>
21+
<p class="text-text-tertiary mb-8 font-medium">
22+
Removing this passkey means you won't be able to use it to sign in anymore.
23+
You can always add a new one later.
24+
{#if isCurrentAccessMethod}
25+
<br /><br />
26+
As you are currently signed in with this passkey, you will be signed out.
27+
{/if}
28+
</p>
29+
<Button onclick={onRemove} variant="primary" danger class="mb-3">
30+
Remove passkey
31+
</Button>
32+
<Button onclick={onClose} variant="tertiary">Cancel</Button>
33+
</Dialog>

src/frontend/src/lib/stores/identity-info.state.svelte.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
lastUsedIdentitiesStore,
1919
lastUsedIdentityStore,
2020
} from "./last-used-identities.store";
21+
import { bufEquals } from "@dfinity/agent";
22+
import { authnMethodEqual } from "$lib/utils/webAuthn";
2123

2224
const fetchIdentityInfo = async () => {
2325
const authenticated = get(authenticatedStore);
@@ -40,6 +42,7 @@ class IdentityInfo {
4042

4143
openIdCredentials = $state<OpenIdCredential[]>([]);
4244
removableOpenIdCredential = $state<OpenIdCredential | null>(null);
45+
removableAuthnMethod = $state<AuthnMethodData | null>(null);
4346

4447
totalAccessMethods = $derived<number>(
4548
this.authnMethods.length + this.openIdCredentials.length,
@@ -160,6 +163,48 @@ class IdentityInfo {
160163
await throwCanisterError(googleRemoveResult);
161164
};
162165

166+
async removePasskey(): Promise<void> {
167+
const { actor, identityNumber } = get(authenticatedStore);
168+
if (isNullish(this.removableAuthnMethod)) {
169+
throw new Error("No passkey to remove");
170+
}
171+
const authnMethod = this.removableAuthnMethod;
172+
const publicKey = new Uint8Array(
173+
"WebAuthn" in authnMethod.authn_method
174+
? authnMethod.authn_method.WebAuthn.pubkey
175+
: authnMethod.authn_method.PubKey.pubkey,
176+
);
177+
this.removableAuthnMethod = null;
178+
const index = this.authnMethods.findIndex((value) =>
179+
authnMethodEqual(value, authnMethod),
180+
);
181+
this.authnMethods.splice(index, 1);
182+
try {
183+
await actor
184+
.authn_method_remove(identityNumber, publicKey)
185+
.then(throwCanisterError);
186+
187+
if (
188+
"WebAuthn" in authnMethod.authn_method &&
189+
this.isCurrentAccessMethod({
190+
passkey: {
191+
credentialId: new Uint8Array(
192+
authnMethod.authn_method.WebAuthn.credential_id,
193+
),
194+
},
195+
})
196+
) {
197+
lastUsedIdentitiesStore.removeIdentity(identityNumber);
198+
this.logout();
199+
return;
200+
}
201+
await this.fetch();
202+
} catch (error) {
203+
this.authnMethods.splice(index, 0, authnMethod);
204+
throw error;
205+
}
206+
}
207+
163208
logout = () => {
164209
// TODO: When we keep a session open we'll need to clean that session.
165210
// For now we just reload the page to make sure all the states are cleared
@@ -173,21 +218,24 @@ class IdentityInfo {
173218
) => {
174219
const lastUsedAuthMethod = get(lastUsedIdentityStore)?.authMethod;
175220
if (
176-
lastUsedAuthMethod &&
221+
nonNullish(lastUsedAuthMethod) &&
177222
"openid" in lastUsedAuthMethod &&
178-
"openid" in accessMethod &&
179-
lastUsedAuthMethod.openid.sub === accessMethod.openid.sub
223+
"openid" in accessMethod
180224
) {
181-
return true;
225+
return (
226+
lastUsedAuthMethod.openid.iss === accessMethod.openid.iss &&
227+
lastUsedAuthMethod.openid.sub === accessMethod.openid.sub
228+
);
182229
}
183230
if (
184-
lastUsedAuthMethod &&
231+
nonNullish(lastUsedAuthMethod) &&
185232
"passkey" in lastUsedAuthMethod &&
186-
"passkey" in accessMethod &&
187-
lastUsedAuthMethod.passkey.credentialId ===
188-
accessMethod.passkey.credentialId
233+
"passkey" in accessMethod
189234
) {
190-
return true;
235+
return bufEquals(
236+
lastUsedAuthMethod.passkey.credentialId,
237+
accessMethod.passkey.credentialId,
238+
);
191239
}
192240
return false;
193241
};

src/frontend/src/lib/utils/webAuthn.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { DeviceData } from "$lib/generated/internet_identity_types";
1+
import type {
2+
AuthnMethodData,
3+
DeviceData,
4+
} from "$lib/generated/internet_identity_types";
25
import { features } from "$lib/legacy/features";
36
import {
47
DummyIdentity,
@@ -7,6 +10,7 @@ import {
710
} from "$lib/utils/iiConnection";
811
import { diagnosticInfo, unknownToString } from "$lib/utils/utils";
912
import { WebAuthnIdentity } from "./webAuthnIdentity";
13+
import { bufEquals } from "@dfinity/agent";
1014

1115
export const constructIdentity = async ({
1216
devices,
@@ -67,3 +71,25 @@ export const lookupAAGUID = async (
6771
).default;
6872
return knownList[aaguid as keyof typeof knownList];
6973
};
74+
75+
/**
76+
* Check if two `AuthnMethodData` values are equal to one another
77+
*/
78+
export const authnMethodEqual = (
79+
a: AuthnMethodData,
80+
b: AuthnMethodData,
81+
): boolean => {
82+
if ("WebAuthn" in a.authn_method && "WebAuthn" in b.authn_method) {
83+
return bufEquals(
84+
new Uint8Array(a.authn_method.WebAuthn.pubkey),
85+
new Uint8Array(b.authn_method.WebAuthn.pubkey),
86+
);
87+
}
88+
if ("PubKey" in a.authn_method && "PubKey" in b.authn_method) {
89+
return bufEquals(
90+
new Uint8Array(a.authn_method.PubKey.pubkey),
91+
new Uint8Array(b.authn_method.PubKey.pubkey),
92+
);
93+
}
94+
return false;
95+
};

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

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import Button from "$lib/components/ui/Button.svelte";
33
import Panel from "$lib/components/ui/Panel.svelte";
4-
import { Link2Off, Plus } from "@lucide/svelte";
4+
import { Link2OffIcon, PlusIcon, Trash2Icon } from "@lucide/svelte";
55
import GoogleIcon from "$lib/components/icons/GoogleIcon.svelte";
66
import identityInfo from "$lib/stores/identity-info.state.svelte";
77
import AccessMethod from "$lib/components/ui/AccessMethod.svelte";
@@ -14,6 +14,9 @@
1414
AuthnMethodData,
1515
OpenIdCredential,
1616
} from "$lib/generated/internet_identity_types";
17+
import RemovePasskeyDialog from "$lib/components/views/RemovePasskeyDialog.svelte";
18+
import { nonNullish } from "@dfinity/utils";
19+
import { handleError } from "$lib/components/utils/error";
1720
import AddAccessMethodWizard from "$lib/components/wizards/AddAccessMethodWizard.svelte";
1821
1922
const MAX_PASSKEYS = 8;
@@ -25,6 +28,7 @@
2528
const isMaxOpenIdCredentialsReached = $derived(
2629
identityInfo.openIdCredentials.length >= 1,
2730
);
31+
2832
const isMaxPasskeysReached = $derived(
2933
identityInfo.authnMethods.length >= MAX_PASSKEYS,
3034
);
@@ -34,6 +38,26 @@
3438
? !isMaxOpenIdCredentialsReached || !isMaxPasskeysReached
3539
: !isMaxOpenIdCredentialsReached,
3640
);
41+
const isRemoveAccessMethodVisible = $derived(
42+
authnMethods.length + openIdCredentials.length > 1,
43+
);
44+
const isRemovableAuthnMethodCurrentAccessMethod = $derived(
45+
nonNullish(identityInfo.removableAuthnMethod) &&
46+
"WebAuthn" in identityInfo.removableAuthnMethod.authn_method &&
47+
identityInfo.isCurrentAccessMethod({
48+
passkey: {
49+
credentialId: new Uint8Array(
50+
identityInfo.removableAuthnMethod.authn_method.WebAuthn.credential_id,
51+
),
52+
},
53+
}),
54+
);
55+
const isRemovableOpenIdCredentialCurrentAccessMethod = $derived(
56+
nonNullish(identityInfo.removableOpenIdCredential) &&
57+
identityInfo.isCurrentAccessMethod({
58+
openid: identityInfo.removableOpenIdCredential,
59+
}),
60+
);
3761
3862
const handleGoogleLinked = (credential: OpenIdCredential) => {
3963
openIdCredentials.push(credential);
@@ -43,6 +67,20 @@
4367
authnMethods.push(authnMethod);
4468
invalidateAll();
4569
};
70+
const handleRemoveOpenIdCredential = async () => {
71+
try {
72+
await identityInfo.removeGoogle();
73+
} catch (error) {
74+
handleError(error);
75+
}
76+
};
77+
const handleRemovePasskey = async () => {
78+
try {
79+
await identityInfo.removePasskey();
80+
} catch (error) {
81+
handleError(error);
82+
}
83+
};
4684
</script>
4785

4886
<h1 class="text-text-primary mb-4 text-3xl font-semibold">Security</h1>
@@ -67,15 +105,15 @@
67105
class="max-md:w-full"
68106
>
69107
<span>Add</span>
70-
<Plus size="1.25rem" />
108+
<PlusIcon size="1.25rem" />
71109
</Button>
72110
</div>
73111
{/if}
74112
</div>
75113
<div
76114
class={`grid grid-cols-[min-content_1fr_min-content] grid-rows-[${identityInfo.totalAccessMethods}]`}
77115
>
78-
{#each identityInfo.authnMethods as authnMethod}
116+
{#each authnMethods as authnMethod}
79117
<div
80118
class="border-border-tertiary col-span-3 grid grid-cols-subgrid border-t py-4"
81119
>
@@ -85,9 +123,18 @@
85123
<PasskeyIcon />
86124
</div>
87125
<AccessMethod accessMethod={authnMethod} />
88-
<!-- for layout consistency -->
89-
<!-- TODO: this is where we would add interactions like removal -->
90-
<div class="min-h-10 min-w-[52px]"></div>
126+
<div class="flex items-center justify-center pr-4">
127+
{#if isRemoveAccessMethodVisible}
128+
<Button
129+
onclick={() => (identityInfo.removableAuthnMethod = authnMethod)}
130+
variant="tertiary"
131+
iconOnly
132+
class="!text-fg-error-secondary"
133+
>
134+
<Trash2Icon size="1.25rem" />
135+
</Button>
136+
{/if}
137+
</div>
91138
</div>
92139
{/each}
93140
{#each openIdCredentials as credential}
@@ -103,14 +150,15 @@
103150
<AccessMethod accessMethod={credential} />
104151

105152
<div class="flex items-center justify-center pr-4">
106-
{#if identityInfo.totalAccessMethods > 1}
153+
{#if isRemoveAccessMethodVisible}
107154
<Button
108-
variant="tertiary"
109-
iconOnly={true}
110155
onclick={() =>
111156
(identityInfo.removableOpenIdCredential = credential)}
157+
variant="tertiary"
158+
iconOnly
159+
class="!text-fg-error-secondary"
112160
>
113-
<Link2Off class="stroke-fg-error-secondary" />
161+
<Link2OffIcon size="1.25rem" />
114162
</Button>
115163
{/if}
116164
</div>
@@ -121,8 +169,17 @@
121169

122170
{#if identityInfo.removableOpenIdCredential}
123171
<RemoveOpenIdCredential
124-
credentialToBeRemoved={identityInfo.removableOpenIdCredential}
172+
onRemove={handleRemoveOpenIdCredential}
125173
onClose={() => (identityInfo.removableOpenIdCredential = null)}
174+
isCurrentAccessMethod={isRemovableOpenIdCredentialCurrentAccessMethod}
175+
/>
176+
{/if}
177+
178+
{#if identityInfo.removableAuthnMethod}
179+
<RemovePasskeyDialog
180+
onRemove={handleRemovePasskey}
181+
onClose={() => (identityInfo.removableAuthnMethod = null)}
182+
isCurrentAccessMethod={isRemovableAuthnMethodCurrentAccessMethod}
126183
/>
127184
{/if}
128185

0 commit comments

Comments
 (0)