feat: implement account passkey functionality

- Added functions for managing account passkeys including creation, listing, updating, and deletion.
- Introduced login methods using account passkeys with options for direct unlock and login-only modes.
- Enhanced error handling and response parsing for passkey-related API calls.
- Updated UI styles for account passkey management components.
- Added new translations for account passkey features in multiple languages.
- Modified network status handling to improve service reachability checks.
This commit is contained in:
shuaiplus
2026-06-10 00:53:41 +08:00
parent 615caf5946
commit 18d3490c4f
38 changed files with 3907 additions and 174 deletions
+488
View File
@@ -0,0 +1,488 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey';
import {
accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus,
accountPasskeyTokenTtlMs,
buildWebAuthnPrfOption,
createAccountPasskeyToken,
getAccountPasskeyRpConfig,
isSerializedEncString,
normalizeAccountPasskeyName,
normalizeAuthenticationResponse,
normalizeRegistrationResponse,
normalizeTransports,
sha256Base64Url,
toSimpleWebAuthnCredential,
userHandleToUserId,
userIdToWebAuthnUserId,
verifyAccountPasskeyToken,
} from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
const MAX_ACCOUNT_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
return parseBodyObject(await request.json());
} catch {
return null;
}
}
async function verifyUserSecret(
env: Env,
user: User,
body: Record<string, any>
): Promise<boolean> {
const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim();
if (!secret) return false;
const storedHash = String(user.masterPasswordHash || '').trim();
if (!storedHash) return false;
const auth = new AuthService(env);
return auth.verifyPassword(secret, storedHash, user.email);
}
function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record<string, unknown> = {}): void {
const err = error instanceof Error ? error : null;
console.error('Account passkey handler failed', {
stage,
name: err?.name || typeof error,
message: err?.message || String(error),
stack: err?.stack,
...details,
});
}
function passkeySetupStageMessage(stage: string): string {
if (stage === 'verify_master_password') return 'verifying master password';
if (stage === 'load_existing_credentials') return 'loading existing passkeys';
if (stage === 'generate_options') return 'generating passkey options';
if (stage === 'save_challenge') return 'saving passkey challenge';
if (stage === 'create_token') return 'creating passkey challenge token';
return 'preparing passkey setup';
}
function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
}
function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
encryptedPrivateKey: string | null;
} {
if (!hasCompletePrfKeySet(body)) {
return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null };
}
const encryptedUserKey = String(body.encryptedUserKey).trim();
const encryptedPublicKey = String(body.encryptedPublicKey).trim();
const encryptedPrivateKey = String(body.encryptedPrivateKey).trim();
if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) {
throw new Error('Invalid encrypted key set');
}
return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey };
}
async function saveChallenge(
storage: StorageService,
scope: AccountPasskeyChallengeScope,
challenge: string,
userId: string | null
): Promise<void> {
const now = Date.now();
await storage.saveAccountPasskeyChallenge({
challengeHash: await sha256Base64Url(challenge),
scope,
userId,
expiresAt: now + accountPasskeyTokenTtlMs(scope),
usedAt: null,
createdAt: now,
});
}
export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: [],
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'Authentication', options.challenge, null);
const token = await createAccountPasskeyToken(env, {
scope: 'Authentication',
challenge: options.challenge,
userId: null,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function assertAccountPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
input: {
token: string;
deviceResponse: unknown;
scope: 'Authentication' | 'UpdateKeySet';
expectedUserId?: string | null;
}
): Promise<{ user: User; credential: AccountPasskeyCredential }> {
const payload = await verifyAccountPasskeyToken(env, input.token, input.scope);
if (!payload) {
throw new Error('Passkey challenge token is invalid or expired');
}
if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) {
throw new Error('Passkey challenge token does not match this user');
}
const response = normalizeAuthenticationResponse(input.deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(
challengeHash,
input.scope,
payload.userId,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential) {
throw new Error('Passkey is not registered for this server');
}
if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
if (!resolvedUserId || resolvedUserId !== credential.userId) {
throw new Error('Passkey user handle does not match this credential');
}
const user = await storage.getUserById(resolvedUserId);
if (!user || user.status !== 'active') {
throw new Error('Passkey user is not available');
}
const { origins } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: true,
advancedFIDOConfig: { userVerification: 'required' },
});
if (!verification.verified || !verification.authenticationInfo.userVerified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return { user, credential };
}
export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
return jsonResponse({
data: credentials.map(accountPasskeyCredentialToResponse),
Data: credentials.map(accountPasskeyCredentialToResponse),
object: 'list',
Object: 'list',
continuationToken: null,
ContinuationToken: null,
});
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let stage = 'verify_master_password';
try {
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
stage = 'load_existing_credentials';
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
if (credentials.length >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
stage = 'generate_options';
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
},
});
(options as any).extensions = {
...((options as any).extensions || {}),
prf: {},
};
stage = 'save_challenge';
await saveChallenge(storage, 'CreateCredential', options.challenge, userId);
stage = 'create_token';
const token = await createAccountPasskeyToken(env, {
scope: 'CreateCredential',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' });
} catch (error) {
logAccountPasskeyHandlerError(stage, error, { userId });
return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500);
}
}
export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId);
const requestedId = String(body.credentialId || body.id || '').trim();
if (requestedId) {
credentials = credentials.filter((credential) => credential.id === requestedId);
if (!credentials.length) return errorResponse('Account passkey not found', 404);
}
if (!credentials.length) return errorResponse('No account passkeys registered', 404);
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'required',
timeout: 60000,
});
await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId);
const token = await createAccountPasskeyToken(env, {
scope: 'UpdateKeySet',
challenge: options.challenge,
userId,
rpId,
});
return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' });
}
export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const storage = new StorageService(env.DB);
const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential');
if (!payload || payload.userId !== userId) {
return errorResponse('Passkey challenge token is invalid or expired', 400);
}
const challengeHash = await sha256Base64Url(payload.challenge);
const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now());
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId);
if (currentCount >= MAX_ACCOUNT_PASSKEYS) {
return errorResponse('Maximum passkey count reached', 400);
}
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const { origins } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: payload.challenge,
expectedOrigin: origins,
expectedRPID: payload.rpId,
requireUserPresence: true,
requireUserVerification: true,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body);
const transports = normalizeTransports(registrationResponse.response.transports);
const credential: AccountPasskeyCredential = {
id: generateUUID(),
userId,
name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: prfKeySet.encryptedUserKey,
encryptedPublicKey: prfKeySet.encryptedPublicKey,
encryptedPrivateKey: prfKeySet.encryptedPrivateKey,
supportsPrf,
createdAt: now,
updatedAt: now,
};
await storage.saveAccountPasskeyCredential(credential);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.create',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
prfStatus: accountPasskeyPrfStatus(credential),
...auditRequestMetadata(request),
},
});
return jsonResponse(accountPasskeyCredentialToResponse(credential));
}
export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let prfKeySet: ReturnType<typeof readPrfKeySet>;
try {
prfKeySet = readPrfKeySet(body);
} catch {
return errorResponse('Invalid encrypted passkey key set', 400);
}
if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) {
return errorResponse('Encrypted passkey key set is required', 400);
}
const storage = new StorageService(env.DB);
let assertion: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
assertion = await assertAccountPasskeyCredential(request, env, storage, {
token: String(body.token || ''),
deviceResponse: body.deviceResponse,
scope: 'UpdateKeySet',
expectedUserId: userId,
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400);
}
const updated = await storage.updateAccountPasskeyEncryption(
userId,
assertion.credential.credentialId,
prfKeySet.encryptedUserKey,
prfKeySet.encryptedPublicKey,
prfKeySet.encryptedPrivateKey
);
if (!updated) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.encryption.enable',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: assertion.credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('Master password verification failed', 400);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId);
if (!deleted) return errorResponse('Passkey not found', 404);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.passkey.delete',
category: 'security',
level: 'info',
targetType: 'accountPasskey',
targetId: credentialId,
metadata: auditRequestMetadata(request),
});
return jsonResponse({ success: true });
}
export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) {
return buildWebAuthnPrfOption(credential);
}
+124
View File
@@ -15,6 +15,10 @@ import {
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import {
assertAccountPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -423,6 +427,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier;
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
deviceResponse = JSON.parse(deviceResponse);
} catch {
return identityErrorResponse('Invalid passkey response', 'invalid_request', 400);
}
}
if (!token || !deviceResponse) {
return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400);
}
let asserted: Awaited<ReturnType<typeof assertAccountPasskeyCredential>>;
try {
asserted = await assertAccountPasskeyCredential(request, env, storage, {
token,
deviceResponse,
scope: 'Authentication',
});
} catch (error) {
await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, {
actorUserId: null,
action: 'auth.passkey.login.failed',
category: 'auth',
level: 'warn',
targetType: 'accountPasskey',
targetId: null,
metadata: {
grantType,
reason: error instanceof Error ? error.message : 'assertion_failed',
...auditRequestMetadata(request),
},
});
return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400);
}
const { user, credential } = asserted;
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo);
if (deviceSession) {
await storage.upsertDevice(
user.id,
deviceSession.identifier,
deviceInfo.deviceName,
deviceInfo.deviceType,
deviceSession.sessionStamp
);
}
await rateLimit.clearLoginAttempts(loginIdentifier);
const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
await safeWriteAuditEvent(env, {
actorUserId: user.id,
action: 'auth.passkey.login.success',
category: 'auth',
level: 'info',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: {
grantType,
webSession: shouldUseWebSession(request),
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
const response: TokenResponse = {
access_token: accessToken,
expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
Key: user.key,
PrivateKey: user.privateKey,
AccountKeys: accountKeys,
accountKeys: accountKeys,
Kdf: user.kdfType,
KdfIterations: user.kdfIterations,
KdfMemory: user.kdfMemory,
KdfParallelism: user.kdfParallelism,
ForcePasswordReset: false,
ResetMasterPassword: false,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
const baseResponse = jsonResponse(response);
return shouldUseWebSession(request)
? withWebRefreshCookie(request, baseResponse, refreshToken)
: baseResponse;
} else if (grantType === 'client_credentials') {
// Login with client credentials
const clientId = body.client_id;
+22 -4
View File
@@ -10,6 +10,7 @@ import {
buildUserDecryptionOptions,
} from '../utils/user-decryption';
import { buildDomainsResponse } from '../services/domain-rules';
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
// CONTRACT:
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
@@ -20,13 +21,14 @@ function buildSyncCacheRequest(
request: Request,
userId: string,
revisionDate: string,
accountPasskeyCacheTag: string,
excludeDomains: boolean,
excludeSends: boolean,
preserveRepairableUris: boolean
): Request {
const url = new URL(request.url);
const cacheUrl = new URL(
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`,
url.origin
);
return new Request(cacheUrl.toString(), { method: 'GET' });
@@ -57,8 +59,19 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
return errorResponse('User not found', 404);
}
const revisionDate = await storage.getRevisionDate(userId);
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends, preserveRepairableUris);
const [revisionDate, accountPasskeys] = await Promise.all([
storage.getRevisionDate(userId),
storage.getAccountPasskeyCredentialsByUserId(userId),
]);
const accountPasskeyCacheTag = accountPasskeys
.map((credential) => [
credential.id,
credential.updatedAt,
credential.supportsPrf ? '1' : '0',
credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0',
].join(':'))
.join(',');
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris);
const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) {
return cachedResponse;
@@ -72,7 +85,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
]);
const accountKeys = buildAccountKeys(user);
const userDecryptionOptions = buildUserDecryptionOptions(user);
const webAuthnPrfOptions = accountPasskeys
.map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const profile: ProfileResponse = {
id: user.id,
@@ -138,6 +154,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
WebAuthnPrfOptions: webAuthnPrfOptions,
Object: 'userDecryption',
},
UserDecryptionOptions: userDecryptionOptions,