feat(oauth): discoverable passkey authentication

Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
Lewis
2026-04-12 11:16:58 +00:00
committed by Tangled
parent 52c22060f3
commit 34f050a1f0
28 changed files with 751 additions and 269 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts" module>
const EXAMPLE_HANDLES = [
"nel.pet",
"lewis.moe",
"oyster.cafe",
"llaama.bsky.social",
"debugman.wizardry.systems",
"nonbinary.computer",
@@ -15,9 +15,10 @@
session: Session
hasPassword: boolean
onPasskeysChanged?: (count: number) => void
onReauthRequired: (methods: string[], retryAction: () => Promise<void>) => void
}
let { session, hasPassword, onPasskeysChanged }: Props = $props()
let { session, hasPassword, onPasskeysChanged, onReauthRequired }: Props = $props()
interface Passkey {
id: string
@@ -81,6 +82,14 @@
}
}
function handleReauthError(e: unknown, fallback: string, retryAction: () => Promise<void>) {
if (e instanceof ApiError && e.error === 'ReauthRequired') {
onReauthRequired(e.reauthMethods || ['password'], retryAction)
} else {
toast.error(e instanceof ApiError ? e.message : fallback)
}
}
async function handleDeletePasskey(id: string) {
const passkey = passkeys.find(p => p.id === id)
if (!confirm($_('security.deletePasskeyConfirm', { values: { name: passkey?.friendlyName || 'this passkey' } }))) return
@@ -89,7 +98,7 @@
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
handleReauthError(e, 'Failed to delete passkey', () => handleDeletePasskey(id))
}
}
@@ -282,6 +282,7 @@
{session}
{hasPassword}
onPasskeysChanged={(count) => passkeyCount = count}
onReauthRequired={handleReauthRequired}
/>
<TotpSection
+1
View File
@@ -522,6 +522,7 @@
"passkeyHintNotAvailable": "No passkey registered",
"passwordPlaceholder": "Password",
"usePasskey": "Use passkey",
"passkeyNotAllowed": "No passkey available for this site",
"orUseCredentials": "or",
"verificationResent": "Verification code sent"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ei pääsyavainta",
"passwordPlaceholder": "Salasana",
"usePasskey": "Käytä pääsyavainta",
"passkeyNotAllowed": "Pääsyavainta ei ole saatavilla tälle sivustolle",
"orUseCredentials": "tai",
"verificationResent": "Vahvistuskoodi lähetetty"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "パスキーなし",
"passwordPlaceholder": "パスワード",
"usePasskey": "パスキーを使用",
"passkeyNotAllowed": "このサイトで利用可能なパスキーがありません",
"orUseCredentials": "または",
"verificationResent": "確認コードを送信しました"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "패스키 없음",
"passwordPlaceholder": "비밀번호",
"usePasskey": "패스키 사용",
"passkeyNotAllowed": "이 사이트에 사용 가능한 패스키가 없습니다",
"orUseCredentials": "또는",
"verificationResent": "인증 코드 전송됨"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "Ingen nyckel registrerad",
"passwordPlaceholder": "Lösenord",
"usePasskey": "Använd nyckel",
"passkeyNotAllowed": "Ingen nyckel tillgänglig för denna webbplats",
"orUseCredentials": "eller",
"verificationResent": "Verifieringskod skickad"
},
+1
View File
@@ -509,6 +509,7 @@
"passkeyHintNotAvailable": "未注册通行密钥",
"passwordPlaceholder": "密码",
"usePasskey": "使用通行密钥",
"passkeyNotAllowed": "此站点没有可用的通行密钥",
"orUseCredentials": "或",
"verificationResent": "验证码已发送"
},
+72 -92
View File
@@ -38,13 +38,8 @@
let submitting = $state(false)
let error = $state<string | null>(null)
let verificationResent = $state(false)
let hasPasskeys = $state(false)
let hasTotp = $state(false)
let hasPassword = $state(true)
let isDelegated = $state(false)
let userDid = $state<string | null>(null)
let checkingSecurityStatus = $state(false)
let securityStatusChecked = $state(false)
let passkeySupported = $state(false)
let clientName = $state<string | null>(null)
@@ -160,31 +155,27 @@
let checkTimeout: ReturnType<typeof setTimeout> | null = null
let checkingDelegation = false
$effect(() => {
if (checkTimeout) {
clearTimeout(checkTimeout)
}
hasPasskeys = false
hasTotp = false
securityStatusChecked = false
isDelegated = false
if (username.length >= 3) {
checkTimeout = setTimeout(() => checkUserSecurityStatus(), 500)
checkTimeout = setTimeout(() => checkDelegationStatus(), 500)
}
})
async function checkUserSecurityStatus() {
if (!username || checkingSecurityStatus) return
checkingSecurityStatus = true
async function checkDelegationStatus() {
if (!username || checkingDelegation) return
checkingDelegation = true
try {
const response = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(username)}`)
if (response.ok) {
const data = await response.json()
hasPasskeys = passkeySupported && data.hasPasskeys === true
hasTotp = data.hasTotp === true
hasPassword = data.hasPassword !== false
isDelegated = data.isDelegated === true
userDid = data.did || null
securityStatusChecked = true
if (isDelegated && data.did) {
const requestUri = getRequestUri()
@@ -198,19 +189,16 @@
}
}
} catch {
hasPasskeys = false
hasTotp = false
hasPassword = true
isDelegated = false
} finally {
checkingSecurityStatus = false
checkingDelegation = false
}
}
async function handlePasskeyLogin() {
const requestUri = getRequestUri()
if (!requestUri || !username) {
if (!requestUri) {
error = $_('common.error')
return
}
@@ -220,16 +208,18 @@
verificationResent = false
try {
const body: Record<string, string> = { request_uri: requestUri }
if (username.trim()) {
body.identifier = username
}
const startResponse = await fetch('/oauth/passkey/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
request_uri: requestUri,
identifier: username
})
body: JSON.stringify(body)
})
if (!startResponse.ok) {
@@ -306,9 +296,9 @@
} catch (e) {
console.error('Passkey login error:', e)
if (e instanceof DOMException && e.name === 'NotAllowedError') {
error = $_('common.error')
error = $_('oauth.login.passkeyNotAllowed')
} else {
error = `${$_('common.error')}: ${e instanceof Error ? e.message : String(e)}`
error = e instanceof Error ? e.message : String(e)
}
submitting = false
}
@@ -439,17 +429,15 @@
</div>
{/if}
{#if passkeySupported && username.length >= 3}
<div class="auth-methods" class:single-method={!hasPassword}>
{#if passkeySupported}
<div class="auth-methods">
<div class="passkey-method">
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
<button
type="button"
style="width: 100%"
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
onclick={handlePasskeyLogin}
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
disabled={submitting}
>
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
@@ -459,80 +447,72 @@
<span class="passkey-text">
{#if submitting}
{$_('oauth.login.authenticating')}
{:else if checkingSecurityStatus || !securityStatusChecked}
{$_('oauth.login.checkingPasskey')}
{:else if hasPasskeys}
{$_('oauth.login.usePasskey')}
{:else}
{$_('oauth.login.passkeyNotSetUp')}
{$_('oauth.login.usePasskey')}
{/if}
</span>
</button>
</div>
{#if hasPassword}
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
<div class="method-divider">
<span>{$_('oauth.login.orUsePassword')}</span>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<div class="password-method">
<h3>{$_('oauth.login.password')}</h3>
<div class="field">
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
placeholder={$_('oauth.login.passwordPlaceholder')}
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</div>
</div>
{:else}
{#if hasPassword || !securityStatusChecked}
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<div>
<label for="password">{$_('oauth.login.password')}</label>
<input
id="password"
type="password"
bind:value={password}
disabled={submitting}
required
autocomplete="current-password"
/>
</div>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<label class="remember-device">
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
<span>{$_('oauth.login.rememberDevice')}</span>
</label>
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
<div class="actions">
<button type="button" class="ghost sm" onclick={handleCancel} disabled={submitting}>
{$_('common.cancel')}
</button>
<button type="submit" disabled={submitting || !username || !password}>
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
</button>
</div>
{/if}
</form>