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
+107 -19
View File
@@ -1,15 +1,21 @@
import {
createAuthedFetch,
deriveLoginHashLocally,
getAccountPasskeyAssertionOptions,
getProfile,
loadProfileSnapshot,
loadSession,
loginWithAccountPasskeyAssertion,
loginWithPassword,
refreshAccessToken,
recoverTwoFactor,
registerAccount,
unlockVaultKey,
} from '@/lib/api/auth';
import {
assertAccountPasskey,
unlockVaultKeyWithAccountPasskeyPrf,
} from '@/lib/account-passkeys';
import { readInviteCodeFromUrl } from '@/lib/app-support';
import { t, translateServerError } from '@/lib/i18n';
import {
@@ -21,7 +27,7 @@ import {
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
email: string;
@@ -30,6 +36,12 @@ export interface PendingTotp {
kdfIterations: number;
}
export interface PendingPasskeyPassword {
token: TokenSuccess;
email: string;
kdfIterations: number;
}
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
export interface BootstrapAppResult {
@@ -61,6 +73,11 @@ export type PasswordLoginResult =
| { kind: 'totp'; pendingTotp: PendingTotp }
| { kind: 'error'; message: string };
export type PasskeyLoginResult =
| { kind: 'success'; login: CompletedLogin }
| { kind: 'password'; pendingPasskeyPassword: PendingPasskeyPassword }
| { kind: 'error'; message: string };
export interface RecoverTwoFactorResult {
login: CompletedLogin | null;
newRecoveryCode: string | null;
@@ -92,6 +109,7 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) return session;
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
}
@@ -107,16 +125,6 @@ function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
if (typeof AbortController === 'undefined') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
cancel: () => clearTimeout(timer),
};
}
function readWindowBootstrap(): WebBootstrapResponse {
if (typeof window === 'undefined') return {};
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
@@ -270,8 +278,10 @@ export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
if (hasOfflineUnlockRecord(session.email)) {
const serviceReachable = await probeNodeWardenService();
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true;
if (hasOfflineUnlock) {
serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return {
session,
@@ -282,7 +292,7 @@ export async function hydrateLockedSession(
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
if (hasOfflineUnlockRecord(session.email)) {
if (hasOfflineUnlock && !serviceReachable) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -345,6 +355,36 @@ export async function completeLogin(
};
}
function readPasskeyPrfOption(token: TokenSuccess): AccountPasskeyPrfOption | null {
const options = (token.UserDecryptionOptions || token.userDecryptionOptions || null) as any;
return options?.WebAuthnPrfOption || options?.webAuthnPrfOption || null;
}
async function completeLoginWithVaultKeys(
token: TokenSuccess,
email: string,
keys: { symEncKey: string; symMacKey: string }
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = {
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
};
const tempFetch = createAuthedFetch(
() => baseSession,
() => {}
);
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
return {
session: { ...baseSession, ...keys },
profile,
profilePromise: getProfile(tempFetch),
};
}
export async function performPasswordLogin(
email: string,
password: string,
@@ -380,6 +420,58 @@ export async function performPasswordLogin(
};
}
export async function performPasskeyLogin(fallbackIterations: number): Promise<PasskeyLoginResult> {
try {
const options = await getAccountPasskeyAssertionOptions();
const assertion = await assertAccountPasskey(options);
const token = await loginWithAccountPasskeyAssertion(assertion);
if (!('access_token' in token) || !token.access_token) {
const tokenError = token as { error_description?: string; error?: string };
return {
kind: 'error',
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
};
}
const email = (decodeAccessTokenClaims(token.access_token).email || '').trim().toLowerCase();
if (!email) {
return { kind: 'error', message: t('txt_login_failed') };
}
const prfOption = readPasskeyPrfOption(token);
if (prfOption && assertion.prfKey) {
const keys = await unlockVaultKeyWithAccountPasskeyPrf(assertion.prfKey, prfOption);
return {
kind: 'success',
login: await completeLoginWithVaultKeys(token, email, keys),
};
}
return {
kind: 'password',
pendingPasskeyPassword: {
token,
email,
kdfIterations: kdfIterationsFromLogin(token, fallbackIterations),
},
};
} catch (error) {
return {
kind: 'error',
message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_login_failed'),
};
}
}
export async function completePasskeyPasswordLogin(
pending: PendingPasskeyPassword,
password: string
): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
}
export async function performTotpLogin(
pendingTotp: PendingTotp,
totpCode: string,
@@ -479,22 +571,18 @@ export async function performUnlock(
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
signal: abortable?.controller.signal,
});
} catch {
if (hasOfflineUnlock) {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return unlockOffline();
}
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
} finally {
abortable?.cancel();
}
if ('access_token' in token && token.access_token) {