feat: implement usernameless Passkey login flow using discoverable credentials

This commit is contained in:
2026-05-28 10:45:33 +02:00
parent d58e222fcb
commit 50371b9297
3 changed files with 89 additions and 75 deletions
+44 -30
View File
@@ -40,14 +40,13 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!username.trim()) return
const handleLogin = async (e?: React.FormEvent) => {
if (e) e.preventDefault()
setLoading(true)
setError(null)
try {
const result = await loginUser(username.trim())
const result = await loginUser()
if (result.verified) {
if (result.prfSuccess) {
// Biometric E2E decryption succeeded
@@ -55,6 +54,9 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
} else {
// Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase
setEncryptedPayloads(result.encryptedPayloads)
if (result.username) {
setUsername(result.username)
}
setShowRecoveryFallback(true)
}
}
@@ -72,7 +74,8 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
setLoading(true)
setError(null)
try {
const success = await completeLoginWithRecovery(username.trim(), recoveryInput.trim(), encryptedPayloads)
const resolvedUser = username.trim() || encryptedPayloads.username
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
if (success) {
onAuthenticated()
} else {
@@ -180,40 +183,51 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<p className="tagline">{t('auth.tagline')}</p>
</div>
<form className="auth-form">
<div className="input-group">
<input
type="text"
className="input-text"
placeholder="Username / Skipper Name"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
style={{ width: '100%', padding: '16px' }}
>
{loading ? 'Processing...' : t('auth.login')}
</button>
{/* Separator */}
<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>
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>or register</span>
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
</div>
{error && <div className="auth-error">{error}</div>}
{/* Registration form */}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
<div className="input-group">
<input
type="text"
className="input-text"
placeholder="Username / Skipper Name"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
<div className="auth-submit-actions">
<button
type="button"
className="btn primary"
onClick={handleLogin}
disabled={loading || !username.trim()}
>
{loading ? 'Processing...' : t('auth.login')}
</button>
<button
type="button"
type="submit"
className="btn secondary"
onClick={handleRegister}
disabled={loading || !username.trim()}
style={{ width: '100%' }}
>
{t('auth.register')}
</button>
</div>
</form>
</form>
{error && <div className="auth-error">{error}</div>}
</div>
<div className="auth-footer">
<button className="btn-icon-text" onClick={toggleLanguage}>
+12 -6
View File
@@ -113,15 +113,17 @@ export async function registerUser(username: string): Promise<RegistrationResult
export interface LoginResult {
verified: boolean
prfSuccess: boolean
username?: string
encryptedPayloads?: {
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
userId: string
username: string
}
}
export async function loginUser(username: string): Promise<LoginResult> {
export async function loginUser(username?: string): Promise<LoginResult> {
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
method: 'POST',
@@ -154,8 +156,8 @@ export async function loginUser(username: string): Promise<LoginResult> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse
credentialResponse,
challenge: options.challenge
})
})
@@ -169,6 +171,8 @@ export async function loginUser(username: string): Promise<LoginResult> {
return { verified: false, prfSuccess: false }
}
const resolvedUsername = result.username
// Try to decrypt master key using biometric PRF results
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
@@ -182,9 +186,9 @@ export async function loginUser(username: string): Promise<LoginResult> {
prfKey
)
activeMasterKey = decryptedMaster
localStorage.setItem('active_username', username)
localStorage.setItem('active_username', resolvedUsername)
localStorage.setItem('active_userid', result.userId)
return { verified: true, prfSuccess: true }
return { verified: true, prfSuccess: true, username: resolvedUsername }
} catch (e) {
console.warn('PRF decryption failed, falling back to recovery phrase:', e)
}
@@ -194,11 +198,13 @@ export async function loginUser(username: string): Promise<LoginResult> {
return {
verified: true,
prfSuccess: false,
username: resolvedUsername,
encryptedPayloads: {
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag,
userId: result.userId
userId: result.userId,
username: resolvedUsername
}
}
}
+33 -39
View File
@@ -16,6 +16,7 @@ const origin = process.env.ORIGIN || 'http://localhost:5173'
// In-memory challenge stores
const registrationChallenges = new Map<string, string>()
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
const activeChallenges = new Set<string>()
// 1. Generate Registration Options
router.post('/register-options', async (req, res) => {
@@ -127,33 +128,31 @@ router.post('/register-verify', async (req, res) => {
router.post('/login-options', async (req, res) => {
try {
const { username } = req.body
if (!username) {
return res.status(400).json({ error: 'Username is required' })
}
const user = await prisma.user.findUnique({
where: { username },
include: { credentials: true }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
// If username is supplied, we do a targeted login, otherwise usernameless
let allowCredentials: any[] = []
if (username) {
const user = await prisma.user.findUnique({
where: { username },
include: { credentials: true }
})
if (user) {
allowCredentials = user.credentials.map(cred => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key',
transports: cred.transports as any[]
}))
}
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user.credentials.map(cred => ({
id: Buffer.from(cred.credentialId, 'base64url'),
type: 'public-key',
transports: cred.transports as any[]
})),
allowCredentials,
userVerification: 'preferred'
})
authenticationChallenges.set(username, {
challenge: options.challenge,
userId: user.id
})
// Store challenge
activeChallenges.add(options.challenge)
return res.json(options)
} catch (error: any) {
@@ -165,27 +164,32 @@ router.post('/login-options', async (req, res) => {
// 4. Verify Authentication Response
router.post('/login-verify', async (req, res) => {
try {
const { username, credentialResponse } = req.body
if (!username || !credentialResponse) {
return res.status(400).json({ error: 'Username and credentialResponse are required' })
const { credentialResponse, challenge } = req.body
if (!credentialResponse || !challenge) {
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
}
const expectedChallengeInfo = authenticationChallenges.get(username)
if (!expectedChallengeInfo) {
// Verify challenge
if (!activeChallenges.has(challenge)) {
return res.status(400).json({ error: 'Challenge not found or expired' })
}
activeChallenges.delete(challenge)
// Find the credential in DB
const dbCred = await prisma.credential.findUnique({
where: { credentialId: credentialResponse.id }
where: { credentialId: credentialResponse.id },
include: { user: true }
})
if (!dbCred || dbCred.userId !== expectedChallengeInfo.userId) {
return res.status(400).json({ error: 'Credential not recognized for this user' })
if (!dbCred) {
return res.status(400).json({ error: 'Credential not recognized' })
}
const user = dbCred.user
const verification = await verifyAuthenticationResponse({
response: credentialResponse,
expectedChallenge: expectedChallengeInfo.challenge,
expectedChallenge: challenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
@@ -205,20 +209,10 @@ router.post('/login-verify', async (req, res) => {
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
})
authenticationChallenges.delete(username)
// Retrieve user keys
const user = await prisma.user.findUnique({
where: { username }
})
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
return res.json({
verified: true,
userId: user.id,
username: user.username,
encryptedMasterKeyPrf: user.encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv: user.encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag: user.encryptedMasterKeyPrfTag,