mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
@@ -2,11 +2,13 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
|
||||
import { t, translateServerError } from '../i18n';
|
||||
import type { AuthorizedDevice } from '../types';
|
||||
import type {
|
||||
AccountPasskeyCredential,
|
||||
Profile,
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
@@ -281,6 +283,40 @@ export async function loginWithPassword(
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyAssertionOptions(): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await fetch('/identity/accounts/webauthn/assertion-options');
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(json?.error_description || json?.error, t('txt_login_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskeyAssertion): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'webauthn');
|
||||
body.set('token', assertion.token);
|
||||
body.set('deviceResponse', JSON.stringify(assertion.deviceResponse));
|
||||
body.set('scope', 'api offline_access');
|
||||
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
|
||||
body.set('deviceName', guessDeviceName());
|
||||
body.set('deviceType', '14');
|
||||
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (!resp.ok) return json;
|
||||
return json;
|
||||
}
|
||||
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
@@ -605,6 +641,135 @@ export async function verifyMasterPassword(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccountPasskeyCredential(raw: any): AccountPasskeyCredential {
|
||||
return {
|
||||
id: String(raw?.id || raw?.Id || ''),
|
||||
name: String(raw?.name || raw?.Name || ''),
|
||||
prfStatus: Number(raw?.prfStatus ?? raw?.PrfStatus ?? 2) as 0 | 1 | 2,
|
||||
encryptedPublicKey: raw?.encryptedPublicKey ?? raw?.EncryptedPublicKey ?? null,
|
||||
encryptedUserKey: raw?.encryptedUserKey ?? raw?.EncryptedUserKey ?? null,
|
||||
creationDate: raw?.creationDate ?? raw?.CreationDate,
|
||||
revisionDate: raw?.revisionDate ?? raw?.RevisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskeyCredential[]> {
|
||||
const resp = await authedFetch('/api/webauthn');
|
||||
if (!resp.ok) throw new Error('Failed to load account passkeys');
|
||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||
const rows = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||
return rows.map(normalizeAccountPasskeyCredential).filter((item) => item.id);
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyAttestationOptions(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await authedFetch('/api/webauthn/attestation-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey creation options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyUpdateAssertionOptions(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
credentialId?: string
|
||||
): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await authedFetch('/api/webauthn/assertion-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash, credentialId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function saveAccountPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: {
|
||||
name: string;
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
supportsPrf: boolean;
|
||||
keySet?: AccountPasskeyPrfKeySet | null;
|
||||
}
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const resp = await authedFetch('/api/webauthn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
token: payload.token,
|
||||
deviceResponse: payload.deviceResponse,
|
||||
supportsPrf: payload.supportsPrf,
|
||||
encryptedUserKey: payload.keySet?.encryptedUserKey,
|
||||
encryptedPublicKey: payload.keySet?.encryptedPublicKey,
|
||||
encryptedPrivateKey: payload.keySet?.encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||
}
|
||||
const body = await parseJson<unknown>(resp);
|
||||
return normalizeAccountPasskeyCredential(body);
|
||||
}
|
||||
|
||||
export async function enableAccountPasskeyDirectUnlock(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: {
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
keySet: AccountPasskeyPrfKeySet;
|
||||
}
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/webauthn', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceResponse: payload.deviceResponse,
|
||||
encryptedUserKey: payload.keySet.encryptedUserKey,
|
||||
encryptedPublicKey: payload.keySet.encryptedPublicKey,
|
||||
encryptedPrivateKey: payload.keySet.encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
id: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/webauthn/${encodeURIComponent(id)}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||
const resp = await authedFetch('/api/accounts/revision-date');
|
||||
if (!resp.ok) {
|
||||
|
||||
Reference in New Issue
Block a user