feat: implement usernameless Passkey login flow using discoverable credentials
This commit is contained in:
@@ -40,14 +40,13 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e?: React.FormEvent) => {
|
||||||
e.preventDefault()
|
if (e) e.preventDefault()
|
||||||
if (!username.trim()) return
|
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await loginUser(username.trim())
|
const result = await loginUser()
|
||||||
if (result.verified) {
|
if (result.verified) {
|
||||||
if (result.prfSuccess) {
|
if (result.prfSuccess) {
|
||||||
// Biometric E2E decryption succeeded
|
// Biometric E2E decryption succeeded
|
||||||
@@ -55,6 +54,9 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
} else {
|
} else {
|
||||||
// Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase
|
// Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase
|
||||||
setEncryptedPayloads(result.encryptedPayloads)
|
setEncryptedPayloads(result.encryptedPayloads)
|
||||||
|
if (result.username) {
|
||||||
|
setUsername(result.username)
|
||||||
|
}
|
||||||
setShowRecoveryFallback(true)
|
setShowRecoveryFallback(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +74,8 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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) {
|
if (success) {
|
||||||
onAuthenticated()
|
onAuthenticated()
|
||||||
} else {
|
} else {
|
||||||
@@ -180,7 +183,27 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
<p className="tagline">{t('auth.tagline')}</p>
|
<p className="tagline">{t('auth.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="auth-form">
|
<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>
|
||||||
|
|
||||||
|
{/* Registration form */}
|
||||||
|
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -193,28 +216,19 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error">{error}</div>}
|
|
||||||
|
|
||||||
<div className="auth-submit-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className="btn primary"
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={loading || !username.trim()}
|
|
||||||
>
|
|
||||||
{loading ? 'Processing...' : t('auth.login')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleRegister}
|
|
||||||
disabled={loading || !username.trim()}
|
disabled={loading || !username.trim()}
|
||||||
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{t('auth.register')}
|
{t('auth.register')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="auth-footer">
|
<div className="auth-footer">
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
|
|||||||
@@ -113,15 +113,17 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
verified: boolean
|
verified: boolean
|
||||||
prfSuccess: boolean
|
prfSuccess: boolean
|
||||||
|
username?: string
|
||||||
encryptedPayloads?: {
|
encryptedPayloads?: {
|
||||||
encryptedMasterKeyRec: string
|
encryptedMasterKeyRec: string
|
||||||
encryptedMasterKeyRecIv: string
|
encryptedMasterKeyRecIv: string
|
||||||
encryptedMasterKeyRecTag: string
|
encryptedMasterKeyRecTag: string
|
||||||
userId: 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
|
// 1. Get authentication options
|
||||||
const optionsRes = await fetch(`${API_BASE}/login-options`, {
|
const optionsRes = await fetch(`${API_BASE}/login-options`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -154,8 +156,8 @@ export async function loginUser(username: string): Promise<LoginResult> {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
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 }
|
return { verified: false, prfSuccess: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedUsername = result.username
|
||||||
|
|
||||||
// Try to decrypt master key using biometric PRF results
|
// Try to decrypt master key using biometric PRF results
|
||||||
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
|
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
|
||||||
|
|
||||||
@@ -182,9 +186,9 @@ export async function loginUser(username: string): Promise<LoginResult> {
|
|||||||
prfKey
|
prfKey
|
||||||
)
|
)
|
||||||
activeMasterKey = decryptedMaster
|
activeMasterKey = decryptedMaster
|
||||||
localStorage.setItem('active_username', username)
|
localStorage.setItem('active_username', resolvedUsername)
|
||||||
localStorage.setItem('active_userid', result.userId)
|
localStorage.setItem('active_userid', result.userId)
|
||||||
return { verified: true, prfSuccess: true }
|
return { verified: true, prfSuccess: true, username: resolvedUsername }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('PRF decryption failed, falling back to recovery phrase:', 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 {
|
return {
|
||||||
verified: true,
|
verified: true,
|
||||||
prfSuccess: false,
|
prfSuccess: false,
|
||||||
|
username: resolvedUsername,
|
||||||
encryptedPayloads: {
|
encryptedPayloads: {
|
||||||
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
|
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
|
||||||
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
|
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
|
||||||
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag,
|
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag,
|
||||||
userId: result.userId
|
userId: result.userId,
|
||||||
|
username: resolvedUsername
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-35
@@ -16,6 +16,7 @@ const origin = process.env.ORIGIN || 'http://localhost:5173'
|
|||||||
// In-memory challenge stores
|
// In-memory challenge stores
|
||||||
const registrationChallenges = new Map<string, string>()
|
const registrationChallenges = new Map<string, string>()
|
||||||
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
|
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
|
||||||
|
const activeChallenges = new Set<string>()
|
||||||
|
|
||||||
// 1. Generate Registration Options
|
// 1. Generate Registration Options
|
||||||
router.post('/register-options', async (req, res) => {
|
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) => {
|
router.post('/login-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
if (!username) {
|
|
||||||
return res.status(400).json({ error: 'Username is required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// If username is supplied, we do a targeted login, otherwise usernameless
|
||||||
|
let allowCredentials: any[] = []
|
||||||
|
if (username) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { username },
|
where: { username },
|
||||||
include: { credentials: true }
|
include: { credentials: true }
|
||||||
})
|
})
|
||||||
|
if (user) {
|
||||||
if (!user) {
|
allowCredentials = user.credentials.map(cred => ({
|
||||||
return res.status(404).json({ error: 'User not found' })
|
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||||
|
type: 'public-key',
|
||||||
|
transports: cred.transports as any[]
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID,
|
rpID,
|
||||||
allowCredentials: user.credentials.map(cred => ({
|
allowCredentials,
|
||||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
|
||||||
type: 'public-key',
|
|
||||||
transports: cred.transports as any[]
|
|
||||||
})),
|
|
||||||
userVerification: 'preferred'
|
userVerification: 'preferred'
|
||||||
})
|
})
|
||||||
|
|
||||||
authenticationChallenges.set(username, {
|
// Store challenge
|
||||||
challenge: options.challenge,
|
activeChallenges.add(options.challenge)
|
||||||
userId: user.id
|
|
||||||
})
|
|
||||||
|
|
||||||
return res.json(options)
|
return res.json(options)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -165,27 +164,32 @@ router.post('/login-options', async (req, res) => {
|
|||||||
// 4. Verify Authentication Response
|
// 4. Verify Authentication Response
|
||||||
router.post('/login-verify', async (req, res) => {
|
router.post('/login-verify', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, credentialResponse } = req.body
|
const { credentialResponse, challenge } = req.body
|
||||||
if (!username || !credentialResponse) {
|
if (!credentialResponse || !challenge) {
|
||||||
return res.status(400).json({ error: 'Username and credentialResponse are required' })
|
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedChallengeInfo = authenticationChallenges.get(username)
|
// Verify challenge
|
||||||
if (!expectedChallengeInfo) {
|
if (!activeChallenges.has(challenge)) {
|
||||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
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({
|
const dbCred = await prisma.credential.findUnique({
|
||||||
where: { credentialId: credentialResponse.id }
|
where: { credentialId: credentialResponse.id },
|
||||||
|
include: { user: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!dbCred || dbCred.userId !== expectedChallengeInfo.userId) {
|
if (!dbCred) {
|
||||||
return res.status(400).json({ error: 'Credential not recognized for this user' })
|
return res.status(400).json({ error: 'Credential not recognized' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const user = dbCred.user
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
const verification = await verifyAuthenticationResponse({
|
||||||
response: credentialResponse,
|
response: credentialResponse,
|
||||||
expectedChallenge: expectedChallengeInfo.challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: rpID,
|
expectedRPID: rpID,
|
||||||
authenticator: {
|
authenticator: {
|
||||||
@@ -205,20 +209,10 @@ router.post('/login-verify', async (req, res) => {
|
|||||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
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({
|
return res.json({
|
||||||
verified: true,
|
verified: true,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
encryptedMasterKeyPrf: user.encryptedMasterKeyPrf,
|
encryptedMasterKeyPrf: user.encryptedMasterKeyPrf,
|
||||||
encryptedMasterKeyPrfIv: user.encryptedMasterKeyPrfIv,
|
encryptedMasterKeyPrfIv: user.encryptedMasterKeyPrfIv,
|
||||||
encryptedMasterKeyPrfTag: user.encryptedMasterKeyPrfTag,
|
encryptedMasterKeyPrfTag: user.encryptedMasterKeyPrfTag,
|
||||||
|
|||||||
Reference in New Issue
Block a user