Skip to content

Commit 0d74da4

Browse files
committed
Server initiated passkey updates and deletions
1 parent c0f24ed commit 0d74da4

23 files changed

Lines changed: 1678 additions & 2021 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Delete passkey now uses an exchangeable token to synchronize backend and frontend records. This process is similar to that used for passkey registration. The backend initiates the process, returning a token. The frontend uses this token to perform its client-side cleanup.
13+
14+
- Update passkey now uses an exchangeable token to synchronize backend and frontend records. This process is similar to that used for passkey registration. The backend initiates the process, returning a token. The frontend uses this token to update the device passkeys.
15+
16+
### Removed
17+
18+
- Browser-initiated passkey updates and deletions are no longer possible
19+
20+
### Changed
21+
22+
- It is now only possible to update **all** passkeys for a given `userId`. Previous versions of `@passlock/browser` implied it was possible to update a specific passkey, however the WebAuthn specs don't actually permit this.
23+
1024
## [2.7.0] - 2026-06-12
1125

1226
### Added

examples/sveltekit/src/lib/client/passkeys.ts

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ import * as PasslockBrowser from '@passlock/browser';
1616

1717
import {
1818
DeletePasskeySuccess,
19-
DeletePasskeyWarning,
2019
DeleteUserPasskeysSuccess,
2120
Error,
2221
PasskeyStatusSuccess,
2322
AuthorizedPasskeyAuthentication,
2423
AuthorizedPasskeyRegistration,
2524
UpdatePasskeysSuccess
2625
} from '$lib/shared/schemas';
27-
import { parse, variant } from 'valibot';
26+
import { parse } from 'valibot';
2827
import { resolve } from '$app/paths';
2928
import { fetchData } from './network';
3029

@@ -209,38 +208,36 @@ export type UpdatePasskeysInput = {
209208
* - the user's device or password manager, which shows the username and
210209
* display name during sign-in
211210
*
212-
* The server updates the first two; the browser then uses the credential list
213-
* returned by the server to request the local update.
211+
* The server updates the first two and returns a short-lived token. The browser
212+
* exchanges that token to request the local update.
214213
*/
215-
export const updateUserPasskeys = async (input: UpdatePasskeysInput) => {
214+
export const updateUserPasskeys = async (
215+
input: UpdatePasskeysInput,
216+
config: PasslockClientConfig
217+
) => {
216218
const ERROR_TAG = '@error/UpdatePasskeyError';
217219
const { username, givenName, familyName } = input;
218220
const displayName = `${givenName} ${familyName}`.trim();
219221

220-
// `PATCH /passkeys` updates the Passlock vault and the local SQLite record.
222+
// `PATCH /passkeys` updates the Passlock vault and the local SQLite record,
223+
// then returns a prepared token for browser-side signal instructions.
221224
const serverResult = await fetchData({
222225
url: resolve('/passkeys'),
223226
method: 'PATCH',
224227
body: { username, displayName },
225-
on2xx: (jsonResponse) => {
226-
const { credentials } = parse(UpdatePasskeysSuccess, jsonResponse);
227-
return credentials.length === 0
228-
? ({ _tag: ERROR_TAG, message: 'No passkeys found' } as const)
229-
: ({ _tag: 'Credentials', credentials } as const);
230-
},
228+
on2xx: (jsonResponse) => parse(UpdatePasskeysSuccess, jsonResponse),
231229
orElse: (jsonResponse) => {
232230
const { message } = parse(Error, jsonResponse);
233231
return { _tag: ERROR_TAG, message } as const;
234232
}
235233
});
236234

237-
// Only the credential payload contains enough information for the browser to
238-
// request a local device update.
239-
if (serverResult._tag !== 'Credentials') return serverResult;
235+
if (serverResult._tag === ERROR_TAG) return serverResult;
240236

241-
// This step is purely local to the browser/device, so no tenancy config is
242-
// required.
243-
const clientResult = await PasslockBrowser.updatePasskeyUsernames(serverResult.credentials);
237+
const clientResult = await PasslockBrowser.updatePasskeys(
238+
{ updatePasskeysToken: serverResult.updatePasskeysToken },
239+
config
240+
);
244241

245242
if (clientResult.success) return { _tag: 'UpdatePasskeySuccess' } as const;
246243

@@ -258,20 +255,17 @@ export type DeletePasskeyInput = {
258255
* best-effort browser-side part. The account should stop trusting the passkey
259256
* even if the browser cannot remove it from the local password manager.
260257
*/
261-
export const deletePasskey = async (input: DeletePasskeyInput) => {
258+
export const deletePasskey = async (input: DeletePasskeyInput, config: PasslockClientConfig) => {
262259
const ERROR_TAG = '@error/DeletePasskeyError';
263260
const PAUSED_TAG = '@warning/PasskeyDeletePaused';
264261

265-
// The endpoint can return either a successful deletion payload or a warning
266-
// that the server-side record was already gone.
267-
const EndpointResponse = variant('_tag', [DeletePasskeySuccess, DeletePasskeyWarning]);
268-
269-
// `DELETE /passkeys/[id]` removes the server-side association first.
262+
// `DELETE /passkeys/[id]` removes the server-side association first and
263+
// returns a prepared token for browser-side cleanup.
270264
const serverResult = await fetchData({
271265
url: resolve(`/passkeys/${encodeURIComponent(input.passkeyId)}`),
272266
method: 'DELETE',
273267
body: {},
274-
on2xx: (jsonResponse) => parse(EndpointResponse, jsonResponse),
268+
on2xx: (jsonResponse) => parse(DeletePasskeySuccess, jsonResponse),
275269
orElse: (jsonResponse) => {
276270
const { message } = parse(Error, jsonResponse);
277271
return { _tag: ERROR_TAG, message } as const;
@@ -281,14 +275,23 @@ export const deletePasskey = async (input: DeletePasskeyInput) => {
281275
// If the server still trusts the credential, we must stop here.
282276
if (serverResult._tag === '@error/DeletePasskeyError') return serverResult;
283277

284-
// The local device is already in the desired state from the server's point
285-
// of view, so the caller can treat this as a warning rather than a failure.
286-
if (serverResult._tag === '@warning/PasskeyNotFound') return serverResult;
287-
288278
// Local device deletion is best-effort and browser-dependent.
289-
const clientResult = await PasslockBrowser.deletePasskey(serverResult.deleted);
279+
const clientResult = await PasslockBrowser.deletePasskeys(
280+
{ deletePasskeysToken: serverResult.deletePasskeysToken },
281+
config
282+
);
283+
284+
if (clientResult.success && clientResult.warnings.length === 0) {
285+
return { _tag: 'DeleteSuccess' } as const;
286+
}
290287

291-
if (clientResult.success) return { _tag: 'DeleteSuccess' } as const;
288+
if (clientResult.success) {
289+
const message =
290+
'The passkey was removed from your account, but browser cleanup returned a warning. ' +
291+
'Check your device password manager if the passkey still appears.';
292+
293+
return { _tag: PAUSED_TAG, message } as const;
294+
}
292295

293296
if (clientResult.code === 'PASSKEY_DELETION_UNSUPPORTED') {
294297
const message =
@@ -306,12 +309,11 @@ export const deletePasskey = async (input: DeletePasskeyInput) => {
306309
/**
307310
* Delete every passkey associated with the current account.
308311
*
309-
* `DELETE /passkeys` removes the trusted server-side records and returns the
310-
* deleted credentials for the current user. The browser then tries to remove
311-
* those credentials from the device. Browser limitations are reported as
312-
* warnings so account deletion can continue.
312+
* `DELETE /passkeys` removes the trusted server-side records and returns a
313+
* short-lived token. The browser exchanges that token to request local cleanup.
314+
* Browser limitations are reported as warnings so account deletion can continue.
313315
*/
314-
export const deleteAccountPasskeys = async () => {
316+
export const deleteAccountPasskeys = async (config: PasslockClientConfig) => {
315317
const ERROR_TAG = '@error/DeletePasskeyError';
316318
const PAUSED_TAG = '@warning/PasskeyDeletePaused';
317319

@@ -327,13 +329,28 @@ export const deleteAccountPasskeys = async () => {
327329
});
328330

329331
// If this fails, the account would still trust one or more passkeys.
330-
if (serverResult._tag !== 'DeleteUserPasskeysSuccess') {
332+
if (serverResult._tag === ERROR_TAG) {
331333
return serverResult;
332334
}
333335

334-
const clientResult = await PasslockBrowser.deleteUserPasskeys(serverResult.deleted);
336+
const clientResult = await PasslockBrowser.deletePasskeys(
337+
{ deletePasskeysToken: serverResult.deletePasskeysToken },
338+
config
339+
);
340+
341+
if (clientResult.success && clientResult.warnings.length === 0) {
342+
return { _tag: 'DeleteSuccess' } as const;
343+
}
335344

336-
if (clientResult.success) return { _tag: 'DeleteSuccess' } as const;
345+
if (clientResult.success) {
346+
return {
347+
_tag: PAUSED_TAG,
348+
message:
349+
'Passkeys were removed from your account, but browser cleanup returned warnings. ' +
350+
'Remove any remaining passkeys manually from your device password manager after ' +
351+
'the account is deleted.'
352+
} as const;
353+
}
337354

338355
if (clientResult.code === 'PASSKEY_DELETION_UNSUPPORTED') {
339356
return {

examples/sveltekit/src/lib/server/passkeys.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ import * as PasslockServer from '@passlock/server';
66
* Update the passkey username/display name in both trusted server-side stores:
77
* the Passlock vault and this sample's local SQLite database.
88
*
9-
* The browser still needs to perform a separate local-device update
10-
* afterwards.
9+
* The browser still needs to exchange the returned token and perform a
10+
* separate local-device update afterwards.
1111
*/
12-
export const updatePasskeyUsernames = async (input: {
12+
export const updatePasskeys = async (input: {
1313
userId: number;
1414
username: string;
1515
displayName?: string | undefined;
1616
}) => {
17-
const vaultResult = await PasslockServer.updatePasskeyUsernames(
17+
const vaultResult = await PasslockServer.updatePasskeys(
1818
{
1919
userId: String(input.userId),
2020
username: input.username,

examples/sveltekit/src/lib/shared/schemas.ts

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,41 +22,37 @@ export const AuthorizedPasskeyAuthentication = v.object({
2222
authenticationToken: v.string()
2323
});
2424

25-
/**
26-
* Credential payload returned by the server when the browser should update the
27-
* username/display name shown for a passkey.
28-
*/
29-
export const UpdatedCredential = v.object({
30-
rpId: v.string(),
31-
userId: v.string(),
32-
username: v.string(),
33-
displayName: v.string()
34-
});
35-
36-
export const DeletedCredential = v.object({
37-
credentialId: v.string(),
38-
userId: v.string(),
39-
rpId: v.string()
25+
export const PasskeyManagementWarning = v.object({
26+
code: v.picklist([
27+
'PASSKEY_NOT_FOUND',
28+
'NO_PASSKEYS_FOUND',
29+
'BROWSER_SIGNAL_UNSUPPORTED',
30+
'BROWSER_SIGNAL_FAILED',
31+
'EMPTY_SIGNAL_PAYLOAD'
32+
]),
33+
message: v.string(),
34+
passkeyId: v.optional(v.string())
4035
});
4136

4237
export const UpdatePasskeysSuccess = v.object({
43-
_tag: v.literal('UpdatePasskeySuccess'),
44-
credentials: v.pipe(v.array(UpdatedCredential), v.readonly())
45-
});
46-
47-
export const DeletePasskeyWarning = v.object({
48-
_tag: v.literal('@warning/PasskeyNotFound'),
49-
message: v.string()
38+
_tag: v.literal('PreparedPasskeyUpdate'),
39+
updatePasskeysToken: v.string(),
40+
expiresAt: v.number(),
41+
warnings: v.pipe(v.array(PasskeyManagementWarning), v.readonly())
5042
});
5143

5244
export const DeletePasskeySuccess = v.object({
53-
_tag: v.literal('DeletePasskeySuccess'),
54-
deleted: DeletedCredential
45+
_tag: v.literal('PreparedPasskeyDeletion'),
46+
deletePasskeysToken: v.string(),
47+
expiresAt: v.number(),
48+
warnings: v.pipe(v.array(PasskeyManagementWarning), v.readonly())
5549
});
5650

5751
export const DeleteUserPasskeysSuccess = v.object({
58-
_tag: v.literal('DeleteUserPasskeysSuccess'),
59-
deleted: v.pipe(v.array(DeletedCredential), v.readonly())
52+
_tag: v.literal('PreparedPasskeyDeletion'),
53+
deletePasskeysToken: v.string(),
54+
expiresAt: v.number(),
55+
warnings: v.pipe(v.array(PasskeyManagementWarning), v.readonly())
6056
});
6157

6258
export const PasskeyStatusSuccess = v.object({

examples/sveltekit/src/reset.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@ const findAllPasskeys = async () => {
1313
};
1414

1515
const deletePasskeys = async (passkeyIds: Array<string>) => {
16-
for (const passkeyId of passkeyIds) {
17-
const result = await PasslockServer.deletePasskey({ passkeyId }, getPasslockConfig());
18-
if (!result.success) {
19-
log.warn('Warning: passkey no longer exists in Passlock vault');
16+
if (passkeyIds.length === 0) return;
17+
18+
const result = await PasslockServer.deletePasskeys({ passkeyIds }, getPasslockConfig());
19+
if (!result.success) {
20+
log.warn('Warning: unable to delete passkeys from Passlock vault');
21+
} else {
22+
for (const warning of result.warnings) {
23+
log.warn(warning.message);
2024
}
25+
}
26+
27+
for (const passkeyId of passkeyIds) {
2128
await db.delete(passkeysTable).where(eq(passkeysTable.passkeyId, passkeyId));
2229
console.log(`Deleted passkey ${passkeyId}`);
2330
}

examples/sveltekit/src/routes/account/+page.svelte

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,17 @@
4040
syncingUpdatedEmailPasskeys = true;
4141
clearFormErrors(accountEmailErrors);
4242
43-
const result = await updateUserPasskeys({
44-
username: data.currentEmail,
45-
givenName: data.user?.givenName,
46-
familyName: data.user?.familyName
47-
});
43+
const result = await updateUserPasskeys(
44+
{
45+
username: data.currentEmail,
46+
givenName: data.user?.givenName,
47+
familyName: data.user?.familyName
48+
},
49+
{
50+
tenancyId: data.tenancyId,
51+
endpoint: data.endpoint
52+
}
53+
);
4854
4955
if (result._tag === '@error/UpdatePasskeyError') {
5056
setFormError(
@@ -121,11 +127,17 @@
121127
122128
// Once the server update succeeds, ask the browser to refresh the
123129
// locally stored display name as well.
124-
const result = await updateUserPasskeys({
125-
username: data.currentEmail,
126-
givenName: form.data.givenName,
127-
familyName: form.data.familyName
128-
});
130+
const result = await updateUserPasskeys(
131+
{
132+
username: data.currentEmail,
133+
givenName: form.data.givenName,
134+
familyName: form.data.familyName
135+
},
136+
{
137+
tenancyId: data.tenancyId,
138+
endpoint: data.endpoint
139+
}
140+
);
129141
130142
if (result._tag === '@error/UpdatePasskeyError') {
131143
setFormError(profileErrors, result.message);

examples/sveltekit/src/routes/account/delete/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
5656
// Clear trusted passkeys before the account record itself is deleted.
5757
deletingPasskeys = true;
58-
const result = await deleteAccountPasskeys();
58+
const result = await deleteAccountPasskeys(config);
5959
deletingPasskeys = false;
6060
6161
if (result._tag === '@error/DeletePasskeyError') {

examples/sveltekit/src/routes/passkeys/+page.svelte

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,15 @@
4343
4444
// Deletion is split across server-side trust removal and best-effort
4545
// browser/device cleanup.
46-
const result = await deletePasskey({ passkeyId });
46+
const result = await deletePasskey({ passkeyId }, data);
4747
48-
if (
49-
result._tag === '@error/DeletePasskeyError' ||
50-
result._tag === '@warning/PasskeyDeletePaused'
51-
) {
48+
if (result._tag === '@error/DeletePasskeyError') {
5249
deletingPasskeyId = null;
5350
error = result.message;
5451
return;
5552
}
5653
57-
if (result._tag === '@warning/PasskeyNotFound') {
54+
if (result._tag === '@warning/PasskeyDeletePaused') {
5855
warning = result.message;
5956
} else {
6057
info = 'Passkey deleted from your account';
@@ -90,7 +87,7 @@
9087
{/if}
9188

9289
{#if warning}
93-
<p class="mt-4 text-sm text-warning">{info}</p>
90+
<p class="mt-4 text-sm text-warning">{warning}</p>
9491
{/if}
9592

9693
{#if error}

0 commit comments

Comments
 (0)