fix: Passkey-Login über Plattformen hinweg vereinfachen und stabilisieren.
Merkt Accounts lokal für Ein-Klick-Login ohne Benutzernamen, verbessert PRF-Fallbacks für Windows Hello/Bitwarden und behebt PIN-Session-Probleme. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,9 +7,11 @@ import {
|
|||||||
setLocalPin,
|
setLocalPin,
|
||||||
hasLocalPin,
|
hasLocalPin,
|
||||||
decryptWithLocalPin,
|
decryptWithLocalPin,
|
||||||
getActiveMasterKey
|
getActiveMasterKey,
|
||||||
|
getKnownUsernames,
|
||||||
|
forgetUsername
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
|
|
||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
@@ -21,6 +23,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Accounts that have already authenticated on this device. Used to offer a
|
||||||
|
// one-click login without re-typing the username.
|
||||||
|
const [knownUsers, setKnownUsers] = useState<string[]>(() => getKnownUsernames())
|
||||||
|
|
||||||
// Registration recovery phrase flow
|
// Registration recovery phrase flow
|
||||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
@@ -57,13 +63,21 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (e?: React.FormEvent) => {
|
const handleLogin = async (explicitUsername?: string) => {
|
||||||
if (e) e.preventDefault()
|
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await loginUser()
|
// Pass the username when available so the server returns a concrete
|
||||||
|
// allowCredentials list. A usernameless (discoverable) assertion fails on
|
||||||
|
// some platform authenticators (e.g. Windows Hello); giving the explicit
|
||||||
|
// credential id makes those work. Priority: an explicitly clicked account
|
||||||
|
// > a typed name > the single remembered account > usernameless discovery.
|
||||||
|
const remembered = getKnownUsernames()
|
||||||
|
const target =
|
||||||
|
explicitUsername ||
|
||||||
|
username.trim() ||
|
||||||
|
(remembered.length === 1 ? remembered[0] : undefined)
|
||||||
|
const result = await loginUser(target)
|
||||||
if (result.verified) {
|
if (result.verified) {
|
||||||
if (result.prfSuccess) {
|
if (result.prfSuccess) {
|
||||||
// Biometric E2E decryption succeeded
|
// Biometric E2E decryption succeeded
|
||||||
@@ -115,6 +129,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
|
|
||||||
const handleConfirmRecovery = () => {
|
const handleConfirmRecovery = () => {
|
||||||
setPinSetupUsername(username.trim() || encryptedPayloads?.username || '')
|
setPinSetupUsername(username.trim() || encryptedPayloads?.username || '')
|
||||||
|
// Clear the recovery phrase so the PIN-setup screen can render: the
|
||||||
|
// `recoveryPhrase` branch is evaluated before `showPinSetup`, so leaving it
|
||||||
|
// set would keep the user stuck on the recovery-phrase screen.
|
||||||
|
setRecoveryPhrase(null)
|
||||||
setShowPinSetup(true)
|
setShowPinSetup(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +180,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleForgetUser = (name: string) => {
|
||||||
|
forgetUsername(name)
|
||||||
|
setKnownUsers(getKnownUsernames())
|
||||||
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||||
i18n.changeLanguage(nextLang)
|
i18n.changeLanguage(nextLang)
|
||||||
@@ -373,9 +396,102 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{ width: '100%', padding: '16px' }}
|
style={{ width: '100%', padding: '16px' }}
|
||||||
>
|
>
|
||||||
{loading ? t('auth.processing') : t('auth.login')}
|
{loading
|
||||||
|
? t('auth.processing')
|
||||||
|
: knownUsers.length === 1
|
||||||
|
? t('auth.login_as', { name: knownUsers[0] })
|
||||||
|
: t('auth.login')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Single remembered account: the main button already logs in as them,
|
||||||
|
so just offer a way to forget it (e.g. on a shared device). */}
|
||||||
|
{knownUsers.length === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleForgetUser(knownUsers[0])}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#64748b',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
alignSelf: 'center',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('auth.not_user', { name: knownUsers[0] })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick-login chips for accounts remembered on this device. Each one
|
||||||
|
logs in with its concrete credential, so no username typing needed. */}
|
||||||
|
{knownUsers.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||||
|
{t('auth.quick_login')}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
|
||||||
|
{knownUsers.map((name) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '4px 4px 4px 12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLogin(name)}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#e2e8f0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserRound size={16} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleForgetUser(name)}
|
||||||
|
disabled={loading}
|
||||||
|
title={t('auth.forget_account')}
|
||||||
|
aria-label={t('auth.forget_account')}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '22px',
|
||||||
|
height: '22px',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', margin: '10px 0', width: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', margin: '10px 0', width: '100%' }}>
|
||||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
|
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||||
"register": "Mit Passkey registrieren",
|
"register": "Mit Passkey registrieren",
|
||||||
"login": "Mit Passkey anmelden",
|
"login": "Mit Passkey anmelden",
|
||||||
|
"login_as": "Anmelden als {{name}}",
|
||||||
|
"quick_login": "Schnell-Login",
|
||||||
|
"forget_account": "Account auf diesem Gerät vergessen",
|
||||||
|
"not_user": "Nicht {{name}}?",
|
||||||
"recovery_title": "Ihr Wiederherstellungsschlüssel",
|
"recovery_title": "Ihr Wiederherstellungsschlüssel",
|
||||||
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
|
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
|
||||||
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
|
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"tagline": "Secure, E2E encrypted maritime logbook.",
|
"tagline": "Secure, E2E encrypted maritime logbook.",
|
||||||
"register": "Register with Passkey",
|
"register": "Register with Passkey",
|
||||||
"login": "Login with Passkey",
|
"login": "Login with Passkey",
|
||||||
|
"login_as": "Login as {{name}}",
|
||||||
|
"quick_login": "Quick login",
|
||||||
|
"forget_account": "Forget account on this device",
|
||||||
|
"not_user": "Not {{name}}?",
|
||||||
"recovery_title": "Your Recovery Phrase",
|
"recovery_title": "Your Recovery Phrase",
|
||||||
"recovery_warning": "IMPORTANT: Write down these 12 words. If you lose your Passkey and these words, your data cannot be recovered.",
|
"recovery_warning": "IMPORTANT: Write down these 12 words. If you lose your Passkey and these words, your data cannot be recovered.",
|
||||||
"confirm_recovery": "I have written down the recovery phrase",
|
"confirm_recovery": "I have written down the recovery phrase",
|
||||||
|
|||||||
+109
-64
@@ -49,13 +49,59 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
|
|||||||
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
|
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
|
||||||
const pinKey = await deriveKeyFromPin(pin, username)
|
const pinKey = await deriveKeyFromPin(pin, username)
|
||||||
const encrypted = await encryptBuffer(masterKey, pinKey)
|
const encrypted = await encryptBuffer(masterKey, pinKey)
|
||||||
localStorage.setItem(`pin_encrypted_master_key_${username.toLowerCase()}`, JSON.stringify(encrypted))
|
// Persist the userId alongside the PIN blob so a PIN-only unlock can restore
|
||||||
|
// the full session identity (needed for all authenticated API calls).
|
||||||
|
const userId = localStorage.getItem('active_userid') || ''
|
||||||
|
localStorage.setItem(
|
||||||
|
`pin_encrypted_master_key_${username.toLowerCase()}`,
|
||||||
|
JSON.stringify({ ...encrypted, userId })
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasLocalPin(username: string): boolean {
|
export function hasLocalPin(username: string): boolean {
|
||||||
return !!localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
return !!localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remembered accounts on this device.
|
||||||
|
// A login WITH a username (concrete allowCredentials) works across every tested
|
||||||
|
// platform authenticator (Google Password Manager, Bitwarden, Windows Hello),
|
||||||
|
// whereas a usernameless/discoverable assertion fails on some of them. By
|
||||||
|
// remembering the usernames that have authenticated on this device we can offer
|
||||||
|
// a one-click login without ever asking the user to type their name again.
|
||||||
|
const KNOWN_USERS_KEY = 'daagbox_known_users'
|
||||||
|
|
||||||
|
export function getKnownUsernames(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KNOWN_USERS_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((u) => typeof u === 'string' && u.length > 0) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberUsername(username: string): void {
|
||||||
|
if (!username) return
|
||||||
|
const list = getKnownUsernames()
|
||||||
|
if (list.some((u) => u.toLowerCase() === username.toLowerCase())) return
|
||||||
|
list.push(username)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KNOWN_USERS_KEY, JSON.stringify(list))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to persist known username:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forgetUsername(username: string): void {
|
||||||
|
const list = getKnownUsernames().filter((u) => u.toLowerCase() !== username.toLowerCase())
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KNOWN_USERS_KEY, JSON.stringify(list))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update known usernames:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function removeLocalPin(username: string): void {
|
export function removeLocalPin(username: string): void {
|
||||||
localStorage.removeItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
localStorage.removeItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
}
|
}
|
||||||
@@ -64,12 +110,15 @@ export async function decryptWithLocalPin(pin: string, username: string): Promis
|
|||||||
const stored = localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
const stored = localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
if (!stored) return null
|
if (!stored) return null
|
||||||
|
|
||||||
const { ciphertext, iv, tag } = JSON.parse(stored)
|
const { ciphertext, iv, tag, userId } = JSON.parse(stored)
|
||||||
const pinKey = await deriveKeyFromPin(pin, username)
|
const pinKey = await deriveKeyFromPin(pin, username)
|
||||||
const decrypted = await decryptBuffer(ciphertext, iv, tag, pinKey)
|
const decrypted = await decryptBuffer(ciphertext, iv, tag, pinKey)
|
||||||
|
|
||||||
setActiveMasterKey(decrypted)
|
setActiveMasterKey(decrypted)
|
||||||
localStorage.setItem('active_username', username)
|
localStorage.setItem('active_username', username)
|
||||||
|
if (userId) {
|
||||||
|
localStorage.setItem('active_userid', userId)
|
||||||
|
}
|
||||||
return decrypted
|
return decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,52 +138,12 @@ function base64urlToBuffer(base64url: string): ArrayBuffer {
|
|||||||
return bytes.buffer
|
return bytes.buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomChallengeBase64url(): string {
|
|
||||||
const bytes = new Uint8Array(32)
|
|
||||||
window.crypto.getRandomValues(bytes)
|
|
||||||
let binary = ''
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
binary += String.fromCharCode(bytes[i])
|
|
||||||
}
|
|
||||||
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPrfFirst(clientExtensionResults: any): ArrayBuffer | null {
|
function extractPrfFirst(clientExtensionResults: any): ArrayBuffer | null {
|
||||||
const first = clientExtensionResults?.prf?.results?.first
|
const first = clientExtensionResults?.prf?.results?.first
|
||||||
if (!first) return null
|
if (!first) return null
|
||||||
return typeof first === 'string' ? base64urlToBuffer(first) : first
|
return typeof first === 'string' ? base64urlToBuffer(first) : first
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some authenticators (notably on Chrome/Android and other platforms) only
|
|
||||||
// expose the PRF output during an assertion (`navigator.credentials.get`),
|
|
||||||
// not during credential creation. When that happens we perform a follow-up
|
|
||||||
// authentication against the freshly created credential purely to obtain the
|
|
||||||
// PRF output. The assertion itself is not sent to the server.
|
|
||||||
async function evaluatePrfViaAuthentication(
|
|
||||||
credentialId: string,
|
|
||||||
transports?: string[]
|
|
||||||
): Promise<ArrayBuffer | null> {
|
|
||||||
try {
|
|
||||||
const authOptions: any = {
|
|
||||||
challenge: randomChallengeBase64url(),
|
|
||||||
allowCredentials: [
|
|
||||||
{
|
|
||||||
id: credentialId,
|
|
||||||
type: 'public-key',
|
|
||||||
...(transports && transports.length ? { transports } : {})
|
|
||||||
}
|
|
||||||
],
|
|
||||||
userVerification: 'preferred',
|
|
||||||
extensions: { prf: { eval: { first: PRF_SALT.buffer } } }
|
|
||||||
}
|
|
||||||
const authResponse = await startAuthentication({ optionsJSON: authOptions })
|
|
||||||
return extractPrfFirst(authResponse.clientExtensionResults)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('PRF follow-up authentication during registration failed:', e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegistrationResult {
|
export interface RegistrationResult {
|
||||||
verified: boolean
|
verified: boolean
|
||||||
recoveryPhrase: string
|
recoveryPhrase: string
|
||||||
@@ -197,17 +206,13 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
const prfResults = (clientExtensionResults as any).prf
|
const prfResults = (clientExtensionResults as any).prf
|
||||||
console.log('Registration PRF extension result:', prfResults)
|
console.log('Registration PRF extension result:', prfResults)
|
||||||
|
|
||||||
// Obtain the PRF output. Prefer the value returned by create(); if the
|
// Capture the PRF output if the authenticator already returned it during
|
||||||
// authenticator advertised PRF support but did not return a result, fall
|
// create(). We intentionally do NOT trigger a second assertion here: that
|
||||||
// back to a follow-up assertion to retrieve it.
|
// produces a confusing second OS prompt during sign-up and fails on some
|
||||||
let prfFirstBuffer: ArrayBuffer | null = extractPrfFirst(clientExtensionResults)
|
// platforms (e.g. Windows Hello). Authenticators that only expose PRF during
|
||||||
if (!prfFirstBuffer && prfResults?.enabled) {
|
// an assertion are handled transparently by the lazy PRF enrollment performed
|
||||||
console.log('PRF enabled but no result from create(); performing follow-up assertion')
|
// on the next login (see completeLoginWithRecovery / enroll-prf).
|
||||||
prfFirstBuffer = await evaluatePrfViaAuthentication(
|
const prfFirstBuffer: ArrayBuffer | null = extractPrfFirst(clientExtensionResults)
|
||||||
credentialResponse.id,
|
|
||||||
credentialResponse.response.transports
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prfFirstBuffer) {
|
if (prfFirstBuffer) {
|
||||||
const prfKey = await deriveKeyFromPrf(prfFirstBuffer)
|
const prfKey = await deriveKeyFromPrf(prfFirstBuffer)
|
||||||
@@ -248,6 +253,7 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
setActiveMasterKey(masterKey)
|
setActiveMasterKey(masterKey)
|
||||||
localStorage.setItem('active_username', username)
|
localStorage.setItem('active_username', username)
|
||||||
localStorage.setItem('active_userid', result.userId)
|
localStorage.setItem('active_userid', result.userId)
|
||||||
|
rememberUsername(username)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -296,32 +302,61 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
|
|
||||||
const options = await optionsRes.json()
|
const options = await optionsRes.json()
|
||||||
|
|
||||||
// Add PRF extension evaluation input
|
// Add PRF extension evaluation input.
|
||||||
|
// When the server returned a concrete allowCredentials list we use
|
||||||
|
// `evalByCredential` (keyed by the base64url credential id), which is the
|
||||||
|
// spec-compliant form for assertions with an allow list. Some platform
|
||||||
|
// authenticators (Windows Hello) reject plain `eval` in that case. For a
|
||||||
|
// usernameless/discoverable assertion (empty allow list) `eval` is required.
|
||||||
if (!options.extensions) {
|
if (!options.extensions) {
|
||||||
options.extensions = {}
|
options.extensions = {}
|
||||||
}
|
}
|
||||||
options.extensions.prf = {
|
|
||||||
eval: {
|
// Some platform authenticators (notably Windows Hello) verify the user but
|
||||||
first: PRF_SALT.buffer
|
// then fail the assertion when the PRF extension is requested. Once we have
|
||||||
|
// observed that for a given account we remember it and stop requesting PRF on
|
||||||
|
// subsequent logins, so the user only sees a single OS prompt instead of two.
|
||||||
|
const prfSkipKey = username ? `prf_get_unsupported_${username.toLowerCase()}` : null
|
||||||
|
const skipPrf = prfSkipKey ? localStorage.getItem(prfSkipKey) === '1' : false
|
||||||
|
|
||||||
|
if (!skipPrf) {
|
||||||
|
// When the server returned a concrete allowCredentials list we use
|
||||||
|
// `evalByCredential` (keyed by the base64url credential id), the
|
||||||
|
// spec-compliant form for assertions with an allow list. For a
|
||||||
|
// usernameless/discoverable assertion (empty allow list) `eval` is required.
|
||||||
|
const allowList: any[] = Array.isArray(options.allowCredentials) ? options.allowCredentials : []
|
||||||
|
if (allowList.length > 0) {
|
||||||
|
const evalByCredential: Record<string, { first: ArrayBuffer }> = {}
|
||||||
|
for (const cred of allowList) {
|
||||||
|
if (cred?.id) {
|
||||||
|
evalByCredential[cred.id] = { first: PRF_SALT.buffer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.extensions.prf = { evalByCredential }
|
||||||
|
} else {
|
||||||
|
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Start biometric Passkey verification
|
// 2. Start biometric Passkey verification.
|
||||||
|
// If the PRF-enabled attempt fails, transparently retry once WITHOUT the PRF
|
||||||
|
// extension so the user can still sign in (and fall back to PIN / recovery
|
||||||
|
// phrase for the E2E key). A successful no-PRF retry is a strong signal that
|
||||||
|
// PRF is the culprit, so we persist that to skip PRF next time.
|
||||||
let credentialResponse
|
let credentialResponse
|
||||||
const prfRequested = !!options.extensions?.prf
|
const prfRequested = !!options.extensions?.prf
|
||||||
try {
|
try {
|
||||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const isOptionError = err.name === 'NotSupportedError' ||
|
if (prfRequested) {
|
||||||
err.message?.toLowerCase().includes('options') ||
|
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||||
err.message?.toLowerCase().includes('process') ||
|
|
||||||
err.message?.toLowerCase().includes('unable to')
|
|
||||||
if (prfRequested && isOptionError) {
|
|
||||||
console.warn('Authentication with PRF extension failed, retrying without PRF:', err)
|
|
||||||
if (options.extensions) {
|
if (options.extensions) {
|
||||||
delete options.extensions.prf
|
delete options.extensions.prf
|
||||||
}
|
}
|
||||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||||
|
if (prfSkipKey) {
|
||||||
|
localStorage.setItem(prfSkipKey, '1')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -349,6 +384,16 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
|
|
||||||
const resolvedUsername = result.username
|
const resolvedUsername = result.username
|
||||||
|
|
||||||
|
// The WebAuthn assertion is verified at this point, so persist the identity
|
||||||
|
// immediately. This must happen regardless of how the E2E master key is
|
||||||
|
// ultimately obtained (PRF, PIN or recovery phrase) — otherwise a subsequent
|
||||||
|
// PIN/recovery unlock leaves `active_userid` unset and every API call fails
|
||||||
|
// with "User not authenticated".
|
||||||
|
localStorage.setItem('active_username', resolvedUsername)
|
||||||
|
localStorage.setItem('active_userid', result.userId)
|
||||||
|
// Remember this account so future logins on this device need no typing.
|
||||||
|
rememberUsername(resolvedUsername)
|
||||||
|
|
||||||
// Try to decrypt master key using biometric PRF results
|
// Try to decrypt master key using biometric PRF results
|
||||||
const clientExtensionResults = credentialResponse.clientExtensionResults || {}
|
const clientExtensionResults = credentialResponse.clientExtensionResults || {}
|
||||||
console.log('WebAuthn client extension keys:', Object.keys(clientExtensionResults))
|
console.log('WebAuthn client extension keys:', Object.keys(clientExtensionResults))
|
||||||
|
|||||||
Reference in New Issue
Block a user