Massive refactoring of remote auth and related functionality. (WIP)

This commit is contained in:
Leo Vasanko
2025-12-08 22:39:57 +00:00
parent a54d872b46
commit 99c60f0e16
23 changed files with 2645 additions and 2043 deletions
-1
View File
@@ -3,7 +3,6 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import StatusMessage from '@/components/StatusMessage.vue'
import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AccessDenied.vue'
+5 -19
View File
@@ -1,15 +1,7 @@
<template>
<!-- Remote auth completion mode -->
<RemoteAuthComplete
v-if="remoteAuthToken"
:token="remoteAuthToken"
@back="handleBack"
@completed="handleRemoteCompleted"
/>
<!-- Normal restricted auth mode -->
<RestrictedAuth
v-else
:mode="authMode"
:remote-auth-token="remoteAuthToken"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
@@ -18,19 +10,18 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue'
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
// Check if this is a remote auth URL: /{token} or /auth/{token}
// Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null)
function extractRemoteToken() {
const path = window.location.pathname
// Match /{token} or /auth/{token} where token is a passphrase with dots
const match = path.match(/\/(?:auth\/)?([^/]+)$/)
// Match /auth/{token} where token is a passphrase with dots
const match = path.match(/\/auth\/([^/]+)$/)
if (match) {
const token = match[1]
// Validate it looks like a passphrase (contains dots, likely 5 words)
// Validate it looks like a 5-word passphrase
const parts = token.split('.')
if (parts.length === 5 && parts.every(p => p.length > 0)) {
return token
@@ -68,11 +59,6 @@ function handleBack() {
})
}
function handleRemoteCompleted() {
// Remote auth completed - the other device is now logged in
// This device doesn't need to do anything special
}
onMounted(() => {
// Check for remote auth token in URL
remoteAuthToken.value = extractRemoteToken()
+2 -4
View File
@@ -23,7 +23,6 @@
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
@@ -80,7 +79,7 @@ let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.'
if (!canRegister.value) return 'This authentication link is no longer valid.'
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
})
@@ -120,10 +119,9 @@ async function fetchUserInfo() {
} catch (error) {
console.error('Failed to load user info', error)
const message = error instanceof ApiError
? (error.data?.detail || 'Reset link is invalid or expired.')
? (error.data?.detail || 'The authentication link is invalid or expired.')
: getUserFriendlyErrorMessage(error)
errorMessage.value = message
showMessage(message, 'error', 0)
}
}
-1
View File
@@ -132,7 +132,6 @@ async function handleTerminateSession(session) {
<RegistrationLinkModal
v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
:auto-copy="false"
:user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')"
@copied="onLinkCopied"
+3 -3
View File
@@ -422,9 +422,9 @@ th {
}
.qr-code {
border: 1px solid var(--color-border);
padding: 0.75rem;
background: var(--color-surface);
padding: 1rem;
background: #fff;
box-shadow: var(--shadow-soft);
}
.link-container,
+10 -10
View File
@@ -4,18 +4,17 @@
<h1>📱 Add Another Device</h1>
<p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p>
</header>
<RegistrationLinkModal
inline
:endpoint="'/auth/api/user/create-link'"
:user-name="userName"
:auto-copy="false"
:prefix-copy-with-user-name="!!userName"
show-close-in-inline
@copied="onCopied"
/>
<div class="button-row" style="margin-top:1rem;">
<button @click="showModal = true" class="btn-primary">Generate Registration Link</button>
<button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button>
</div>
<RegistrationLinkModal
v-if="showModal"
endpoint="/auth/api/user/create-link"
:user-name="userName"
@close="showModal = false"
@copied="onCopied"
/>
</section>
</template>
@@ -26,9 +25,10 @@ import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
const authStore = useAuthStore()
const userName = ref(null)
const showModal = ref(false)
const onCopied = () => {
authStore.showMessage('Link copied to clipboard!', 'success', 2500)
authStore.currentView = 'profile'
}
onMounted(async () => {
@@ -1,913 +0,0 @@
<template>
<div class="pairing-entry">
<div class="pairing-header">
<h3>{{ title }}</h3>
<p class="pairing-description">{{ description }}</p>
</div>
<form @submit.prevent="submitCode" class="pairing-form">
<div class="input-row">
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError && !completed, 'is-verified': completed }">
<!-- Colored overlay to show invalid words in red -->
<span v-if="hasInvalidWord" class="input-overlay" aria-hidden="true"><span v-for="(segment, i) in coloredSegments" :key="i" :class="{ 'invalid-word': segment.invalid }">{{ segment.text }}</span></span>
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
:disabled="completed"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input"
:class="{ 'text-hidden': hasInvalidWord }"
@input="handleInput"
@keydown="handleKeydown"
@click="updateAutocomplete"
@select="updateAutocomplete"
/>
<!-- Autocomplete hint overlay: shows text before cursor + hint suffix -->
<span v-if="autocompleteHint && !hasInvalidWord" class="autocomplete-hint" aria-hidden="true"><span class="hint-prefix">{{ autocompletePrefix }}</span><span class="hint-suffix">{{ autocompleteSuffix }}</span></span>
</div>
<!-- Processing status beside input -->
<div v-if="processingStatus" class="processing-status">
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
<span class="processing-spinner-small"></span>
</div>
</div>
<!-- Device info display (shown when 3 words match a request) -->
<div v-if="deviceInfo && !error && !completed" class="device-info">
<p class="device-info-label">📱 Device requesting login:</p>
<div class="device-info-details">
<div class="device-detail">
<span class="detail-label">Site:</span>
<span class="detail-value">{{ deviceInfo.host }}</span>
</div>
<div class="device-detail">
<span class="detail-label">Browser:</span>
<span class="detail-value">{{ deviceInfo.user_agent_pretty }}</span>
</div>
</div>
<button
ref="submitBtnRef"
type="submit"
:disabled="loading"
class="btn-primary"
style="margin-top: 0.75rem; width: 100%;"
>
{{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
</button>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
<!-- Success state -->
<div v-if="completed" class="success-section">
<p class="success-message"> {{ completedMessage }}</p>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter three words' }
})
const emit = defineEmits(['completed', 'error', 'cancelled'])
const inputRef = ref(null)
const submitBtnRef = ref(null)
const code = ref('')
const loading = ref(false)
const isProcessing = ref(false) // Track if we're processing (solving PoW or waiting for server)
const processingStatus = ref('') // 'pow' = computing PoW, 'server' = waiting for server
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
const deviceInfo = ref(null)
const autocompleteHint = ref('')
const hasInvalidWord = ref(false) // Track if any word is invalid (not a valid prefix)
const serverError = ref(false) // Track if server rejected the code
const cursorPos = ref(0) // Track cursor position for autocomplete
// Unified WebSocket state
let ws = null
let wsConnecting = false
let currentChallenge = null // Current PoW challenge from server
let currentWork = null
let powPromise = null // Promise for background PoW computation
let powSolution = null // Solved PoW solution ready to use
let lookupTimeout = null
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
// Get the word at cursor position
function getWordAtCursor(input, cursor) {
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
// Find word boundaries around cursor
let start = cursor
let end = cursor
// Move start back to find word beginning
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) {
start--
}
// Move end forward to find word end
while (end < input.length && /[a-zA-Z]/.test(input[end])) {
end++
}
return {
word: input.slice(start, end),
start,
end
}
}
// Get the current word being typed (the last word without a separator) - kept for validation
function getCurrentWord(input) {
const match = input.match(/[a-zA-Z]+$/)
return match ? match[0] : ''
}
// Get all words from input (normalizes whitespace)
function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
// Count complete words (words followed by a separator)
function countCompleteWords(input) {
// Count words that are followed by a separator
const endsWithSeparator = /[.\s]$/.test(input)
const words = getWords(input)
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
}
// Check validity and return info about each word segment
function analyzeWords(input) {
if (!input) return { valid: true, segments: [] }
const segments = []
const endsWithSeparator = /[.\s]$/.test(input)
// Parse input preserving separators
let pos = 0
const regex = /([a-zA-Z]+)|([.\s]+)/g
let match
while ((match = regex.exec(input)) !== null) {
if (match[1]) {
// Word
segments.push({ text: match[1], isWord: true, start: match.index })
} else if (match[2]) {
// Separator
segments.push({ text: match[2], isWord: false, start: match.index })
}
}
// Determine validity of each word
const words = segments.filter(s => s.isWord)
let allValid = true
words.forEach((wordSeg, idx) => {
const isLastWord = idx === words.length - 1
const word = wordSeg.text.toLowerCase()
if (isLastWord && !endsWithSeparator) {
// Currently being typed - just needs to be valid prefix
wordSeg.invalid = !isValidPrefix(word)
} else {
// Complete word - must be a valid word
wordSeg.invalid = !isValidWord(word)
}
if (wordSeg.invalid) allValid = false
})
return { valid: allValid, segments }
}
// Computed property for colored segments
const coloredSegments = computed(() => {
const { segments } = analyzeWords(code.value)
return segments.map(s => ({
text: s.text,
invalid: s.invalid || false
}))
})
// Check if all completed words are valid and current word is a valid prefix
function checkWordsValidity(input) {
return analyzeWords(input).valid
}
// Check if all words are valid complete words
function allWordsValid(input) {
const words = getWords(input)
return words.length > 0 && words.every(w => isValidWord(w))
}
// Compute autocomplete prefix (text up to and including current word at cursor)
const autocompletePrefix = computed(() => {
if (!autocompleteHint.value || !code.value) return ''
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return ''
// Return text up to end of current word (this part is transparent)
return code.value.slice(0, end)
})
// Compute autocomplete suffix (the completion part only)
const autocompleteSuffix = computed(() => {
if (!autocompleteHint.value || !code.value) return ''
const { word } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return ''
// Return only the remaining characters of the hint
return autocompleteHint.value.slice(word.length)
})
// Check if we have exactly 3 valid words
const hasThreeValidWords = computed(() => {
const words = getWords(code.value)
return words.length === 3 && words.every(w => isValidWord(w))
})
// Normalize code to dot-separated lowercase
function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
// Start solving PoW in background
function startPowSolving() {
if (!currentChallenge || powPromise) return
const challenge = b64dec(currentChallenge)
powPromise = solvePoW(challenge, currentWork).then(solution => {
powSolution = solution
powPromise = null
})
}
// Get the solved PoW solution, waiting if necessary
async function getPowSolution() {
if (powSolution) {
const solution = powSolution
powSolution = null
return solution
}
if (powPromise) {
await powPromise
const solution = powSolution
powSolution = null
return solution
}
// Need to solve now
if (!currentChallenge) {
throw new Error('No PoW challenge available')
}
const challenge = b64dec(currentChallenge)
return await solvePoW(challenge, currentWork)
}
// Update challenge from server response and start solving
function updateChallenge(pow) {
if (pow?.challenge) {
currentChallenge = pow.challenge
currentWork = pow.work
powSolution = null
powPromise = null
// Start solving in background immediately
startPowSolving()
}
}
// Connect to the unified WebSocket endpoint
async function ensureConnection() {
if (ws || wsConnecting) return
wsConnecting = true
try {
const settings = await getSettings()
const authHost = settings?.auth_host
// Single unified endpoint - no code in URL!
const wsPath = '/auth/ws/remote-auth/pair'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// First message is PoW challenge
const msg = await ws.receive_json()
if (msg.status && msg.detail) {
throw new Error(msg.detail)
}
if (!msg.pow?.challenge) {
throw new Error('Server did not send PoW challenge')
}
updateChallenge(msg.pow)
} catch (err) {
console.error('WebSocket connection error:', err)
authStore.showMessage('Pairing service: ' + (err.message || 'Connection failed'), 'error', 4000)
ws = null
throw err // Re-throw so caller knows connection failed
} finally {
wsConnecting = false
}
}
// Update autocomplete hint based on current input and cursor position
function updateAutocomplete() {
// Update cursor position
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
const completeWordCount = countCompleteWords(code.value)
// Don't autocomplete if:
// - we already have 3 complete words
// - no current word at cursor
// - cursor is not at the end of the word (user is editing middle of word)
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
autocompleteHint.value = ''
return
}
// Find unique match for current prefix
const match = getUniqueMatch(word.toLowerCase())
if (match && match !== word.toLowerCase()) {
autocompleteHint.value = match
} else {
autocompleteHint.value = ''
}
}
// Apply autocomplete - complete the current word at cursor
function applyAutocomplete() {
if (!autocompleteHint.value) return false
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return false
// Count how many complete words we have before this one
const before = code.value.slice(0, start)
const wordsBefore = getWords(before).length
// Don't add space after the 3rd word (wordsBefore is 0-indexed count of words before current)
const isThirdWord = wordsBefore === 2
const suffix = isThirdWord ? '' : ' '
// Replace the current word with the full word (+ space if not 3rd word)
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + suffix + after.trimStart()
// Move cursor to after the inserted word
const newPos = start + autocompleteHint.value.length + suffix.length
nextTick(() => {
inputRef.value?.setSelectionRange(newPos, newPos)
cursorPos.value = newPos
})
autocompleteHint.value = ''
return true
}
function handleInput() {
// Normalize: trim trailing whitespace if we have 3 complete words
const words = getWords(code.value)
if (words.length >= 3) {
// Normalize to exactly 3 words with single spaces, no trailing space
const normalized = words.slice(0, 3).join(' ')
if (code.value !== normalized) {
const cursorWasAtEnd = cursorPos.value >= code.value.length
code.value = normalized
// Keep cursor at end if it was there
if (cursorWasAtEnd) {
nextTick(() => {
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
cursorPos.value = normalized.length
})
}
}
}
// Update autocomplete on input
updateAutocomplete()
// Clear previous lookup timeout
if (lookupTimeout) {
clearTimeout(lookupTimeout)
lookupTimeout = null
}
// Reset device info when code changes
deviceInfo.value = null
error.value = null
serverError.value = false
// Check if any word is invalid (not a valid prefix or complete word)
hasInvalidWord.value = !checkWordsValidity(code.value)
// Re-get words after normalization
const currentWords = getWords(code.value)
// Connect to WebSocket on first meaningful input (start solving PoW early)
if (currentWords.length >= 1 && !ws && !wsConnecting) {
ensureConnection()
}
// Check for invalid words when we have 3 words
if (currentWords.length === 3) {
if (!allWordsValid(code.value)) {
// Don't show error message - the red input is enough feedback
return
}
// All words valid, do lookup
lookupTimeout = setTimeout(() => {
lookupDeviceInfo()
}, 150)
}
}
async function lookupDeviceInfo() {
// Don't start a new lookup if we're already processing or loading
if (isProcessing.value || loading.value) return
// Check if current input has 3 valid words
if (!hasThreeValidWords.value) return
const normalizedCode = normalizeCode(code.value)
// Skip if we already successfully looked up this exact code
if (normalizedCode === lastLookedUpCode && deviceInfo.value) {
return
}
isProcessing.value = true
processingStatus.value = 'pow'
error.value = null
serverError.value = false
try {
// Ensure we have a connection
await ensureConnection()
if (!ws) {
throw new Error('Failed to connect')
}
// Get PoW solution (may wait for background computation)
const solution = await getPowSolution()
const powB64 = b64enc(solution)
// Take the field value NOW (after PoW is solved) - user may have changed it
const currentCode = normalizeCode(code.value)
// Check if we still have 3 valid words
if (!hasThreeValidWords.value) {
// User changed input while we were solving PoW - abort this lookup
return
}
// Now waiting for server
processingStatus.value = 'server'
// Send current code + PoW
ws.send_json({
code: currentCode,
pow: powB64
})
// Receive response
const res = await ws.receive_json()
// Update challenge for next request (included in all responses)
updateChallenge(res.pow)
// Check for error response (HTTP status code format)
if (typeof res.status === 'number' && res.status >= 400) {
authStore.showMessage(res.detail || 'Request failed', 'error', 4000)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
// Keep connection open for retries (challenge was updated above)
return
}
if (res.status === 'found' && res.host) {
// Success! Set field to normalized value
code.value = currentCode.replace(/\./g, ' ')
deviceInfo.value = {
host: res.host,
user_agent_pretty: res.user_agent_pretty
}
lastLookedUpCode = currentCode
// Focus the submit button after device info loads
nextTick(() => {
submitBtnRef.value?.focus()
})
} else {
authStore.showMessage('Unexpected response from server', 'error', 3000)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
}
} catch (err) {
console.error('Lookup error:', err)
// Show appropriate message for connection errors
const isDisconnect = err.message?.includes('closed')
authStore.showMessage(
isDisconnect ? 'Connection lost. Please try again.' : (err.message || 'Lookup failed'),
'error',
4000
)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
// Close broken connection
if (ws) {
ws.close()
ws = null
}
} finally {
isProcessing.value = false
processingStatus.value = ''
}
}
function handleKeydown(event) {
// Tab triggers autocomplete if we have a hint
if (event.key === 'Tab') {
if (autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
// Trigger input handling since programmatic changes don't fire input event
handleInput()
return
}
}
// Prevent Tab from moving focus if input has content (keep user in the input)
if (code.value.trim()) {
event.preventDefault()
}
return
}
// Space triggers autocomplete if we have a hint
if (event.key === ' ' && autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
// Trigger input handling since programmatic changes don't fire input event
handleInput()
}
}
}
async function submitCode() {
if (!deviceInfo.value || loading.value) return
loading.value = true
error.value = null
try {
// Ensure we still have the connection
if (!ws) {
await ensureConnection()
}
if (!ws) {
throw new Error('Failed to connect')
}
// Get PoW solution for authenticate request
const solution = await getPowSolution()
const powB64 = b64enc(solution)
// Send authenticate request with PoW
ws.send_json({
authenticate: true,
pow: powB64
})
// Receive authentication options
const res = await ws.receive_json()
// Check for server error response
if (typeof res.status === 'number' && res.status >= 400) {
throw new Error(res.detail || 'Authentication failed')
}
if (!res.optionsJSON) {
throw new Error(res.detail || 'Failed to get authentication options')
}
// Perform WebAuthn authentication
const authResponse = await startAuthentication(res.optionsJSON)
ws.send_json(authResponse)
// Wait for confirmation
const result = await ws.receive_json()
// Check for server error response
if (typeof result.status === 'number' && result.status >= 400) {
throw new Error(result.detail || 'Authentication failed')
}
if (result.status === 'success') {
completed.value = true
completedMessage.value = result.message || 'The other device is now logged in.'
authStore.showMessage('Device authenticated successfully!', 'success', 3000)
emit('completed')
} else {
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
const isDisconnect = err.message?.includes('closed')
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: isDisconnect
? 'Connection lost. Please try again.'
: (err.message || 'Failed to connect')
error.value = message
authStore.showMessage(message, 'error', 4000)
emit('error', message)
} finally {
loading.value = false
// Close the WebSocket after authentication attempt
if (ws) {
ws.close()
ws = null
}
}
}
function reset() {
code.value = ''
error.value = null
serverError.value = false
completed.value = false
completedMessage.value = ''
deviceInfo.value = null
isProcessing.value = false
processingStatus.value = ''
autocompleteHint.value = ''
hasInvalidWord.value = false
lastLookedUpCode = null
// Close WebSocket on reset
if (ws) {
ws.close()
ws = null
}
currentChallenge = null
currentWork = null
powPromise = null
powSolution = null
}
onMounted(() => {
inputRef.value?.focus()
})
onUnmounted(() => {
// Clean up timeout on unmount
if (lookupTimeout) {
clearTimeout(lookupTimeout)
lookupTimeout = null
}
// Clean up WebSocket on unmount
if (ws) {
ws.close()
ws = null
}
})
defineExpose({ reset })
</script>
<style scoped>
.pairing-entry {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pairing-header h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
}
.pairing-description {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.pairing-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
max-width: 21ch;
}
.input-wrapper.has-error .pairing-input {
border-color: var(--color-error, #ef4444);
}
.input-wrapper.has-error .pairing-input:focus {
border-color: var(--color-error, #ef4444);
box-shadow: 0 0 0 2px var(--color-error-alpha, rgba(239, 68, 68, 0.2));
}
.input-wrapper.is-complete .pairing-input {
border-color: var(--color-success, #10b981);
}
.input-wrapper.is-complete .pairing-input:focus {
border-color: var(--color-success, #10b981);
box-shadow: 0 0 0 2px var(--color-success-alpha, rgba(16, 185, 129, 0.2));
}
.input-wrapper.is-verified .pairing-input {
border-color: var(--color-success, #10b981);
background: var(--color-success-bg, rgba(16, 185, 129, 0.1));
}
.input-overlay {
position: absolute;
left: 0;
top: 0;
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
color: var(--color-text);
pointer-events: none;
white-space: pre;
border: 1px solid transparent;
}
.input-overlay .invalid-word {
color: var(--color-error, #ef4444);
}
.pairing-input {
flex: 1;
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface);
color: var(--color-text);
caret-color: var(--color-text);
}
.pairing-input.text-hidden {
color: transparent;
}
.pairing-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha, rgba(59, 130, 246, 0.2));
}
.pairing-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pairing-input::placeholder {
color: var(--color-text-muted);
opacity: 0.6;
}
.autocomplete-hint {
position: absolute;
left: 0;
top: 0;
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
pointer-events: none;
white-space: pre;
}
.autocomplete-hint .hint-prefix {
color: transparent;
}
.autocomplete-hint .hint-suffix {
color: var(--color-text-muted);
opacity: 0.6;
}
.processing-status {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.processing-icon {
font-size: 0.875rem;
}
.processing-spinner-small {
width: 12px;
height: 12px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
padding: 0.875rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
}
.device-info-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.device-info-details {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.device-detail {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
}
.detail-label {
color: var(--color-text-muted);
min-width: 4rem;
}
.detail-value {
color: var(--color-text);
font-weight: 500;
}
.error-message {
margin: 0;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
}
.success-section {
padding: 0.75rem;
background: var(--color-success-bg, rgba(16, 185, 129, 0.1));
border-radius: var(--radius-sm, 4px);
}
.success-message {
margin: 0;
font-size: 0.95rem;
color: var(--color-success, #10b981);
}
</style>
+21 -25
View File
@@ -1,9 +1,9 @@
<template>
<section class="view-root" data-view="profile">
<header class="view-header">
<h1>👋 Welcome!</h1>
<h1>User Profile</h1>
<Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p>
<p class="view-lede">Account dashboard for managing credentials and authenticating with other devices.</p>
</header>
<section class="section-block">
@@ -17,7 +17,20 @@
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@edit-name="openNameDialog"
/>
>
<div class="remote-auth-inline">
<label v-if="!showDeviceInfo" class="remote-auth-label">Code words from remote device:</label>
<RemoteAuth
ref="pairingEntry"
title=""
description=""
placeholder="word word word"
@completed="handlePairingCompleted"
@error="handlePairingError"
@device-info-visible="showDeviceInfo = $event"
/>
</div>
</UserBasicInfo>
</section>
<section class="section-block">
@@ -52,23 +65,6 @@
section-description="Review where you're signed in and end any sessions you no longer recognize."
/>
<section class="section-block">
<div class="section-header">
<h2>Help Another Device Sign In</h2>
<p class="section-description">Enter a code from another device to sign it in using your passkey.</p>
</div>
<div class="section-body">
<PairingCodeEntry
ref="pairingEntry"
title=""
description=""
placeholder="word word word"
@completed="handlePairingCompleted"
@error="handlePairingError"
/>
</div>
</section>
<Modal v-if="showNameDialog" @close="showNameDialog = false">
<h3>Edit Display Name</h3>
<form @submit.prevent="saveName" class="modal-form">
@@ -101,11 +97,8 @@
</section>
<RegistrationLinkModal
v-if="showRegLink"
:endpoint="'/auth/api/user/create-link'"
:auto-copy="false"
:prefix-copy-with-user-name="false"
endpoint="/auth/api/user/create-link"
@close="showRegLink = false"
@copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)"
/>
</section>
</template>
@@ -119,7 +112,7 @@ import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import PairingCodeEntry from '@/components/PairingCodeEntry.vue'
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
@@ -134,6 +127,7 @@ const newName = ref('')
const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const showDeviceInfo = ref(false)
const pairingEntry = ref(null)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -231,5 +225,7 @@ const saveName = async () => {
.logout-row { gap: 1rem; }
.logout-row.single { justify-content: flex-start; }
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
@media (max-width: 720px) { .logout-button { width: 100%; } }
</style>
+161
View File
@@ -0,0 +1,161 @@
<template>
<div class="qr-display">
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<div v-if="showLink && url" class="link-text">{{ displayUrl }}</div>
</a>
</div>
<div v-if="showCopyToast" class="copy-toast">
Link copied to clipboard
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import QRCode from 'qrcode/lib/browser'
const props = defineProps({
url: { type: String, required: true },
showLink: { type: Boolean, default: false }
})
const emit = defineEmits(['copied'])
const qrCanvas = ref(null)
const showCopyToast = ref(false)
let copyToastTimer = null
const displayUrl = computed(() => {
if (!props.url) return ''
return props.url.replace(/^https?:\/\//, '')
})
function drawQR() {
if (!props.url || !qrCanvas.value) {
return
}
try {
// Clear the canvas first
const ctx = qrCanvas.value.getContext('2d')
ctx.clearRect(0, 0, qrCanvas.value.width, qrCanvas.value.height)
// Generate QR code synchronously
QRCode.toCanvas(qrCanvas.value, props.url, {
scale: 6,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
// Remove any inline styles added by QRCode library immediately
qrCanvas.value.removeAttribute('style')
} catch (err) {
console.error('QR code generation failed:', err)
}
}
async function copyLink() {
if (!props.url) return
try {
await navigator.clipboard.writeText(props.url)
showCopyToast.value = true
emit('copied')
if (copyToastTimer) clearTimeout(copyToastTimer)
copyToastTimer = setTimeout(() => {
showCopyToast.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
// Watch for URL changes
watch(() => props.url, () => {
drawQR()
}, { immediate: true })
// Watch for canvas ref becoming available
watch(qrCanvas, () => {
if (qrCanvas.value && props.url) {
drawQR()
}
}, { immediate: true })
</script>
<style scoped>
.qr-display {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.qr-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.qr-link {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: inherit;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
}
.qr-code {
display: block;
width: 200px;
height: 200px;
max-width: 100%;
object-fit: contain;
border-radius: var(--radius-sm, 6px);
background: #ffffff;
cursor: pointer;
}
.link-text {
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
word-break: break-all;
line-height: 1.2;
transition: color 0.2s ease;
}
.qr-link:hover .link-text {
color: var(--color-text);
}
.copy-toast {
position: absolute;
top: -2rem;
left: 50%;
transform: translateX(-50%);
background: var(--color-success);
color: white;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
z-index: 10;
animation: fadeInOut 2s ease-in-out;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; }
10%, 90% { opacity: 1; }
}
</style>
+71 -109
View File
@@ -1,147 +1,109 @@
<template>
<div v-if="!inline && url" class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="dialog-overlay" @keydown.esc.prevent="$emit('close')">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span>
📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Add Another Device</span>
</h2>
<button class="icon-btn" @click="$emit('close')" aria-label="Close"></button>
</div>
<div class="device-link-section">
<div class="qr-container">
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open or scan this link on the device you wish to register to your account.</span>
<br><small>{{ expirationMessage }}</small>
</p>
<!-- Loading state -->
<div v-if="loading" class="loading-state">
<div class="spinner-small"></div>
<span>Generating registration link...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-state">
<p class="error-message">{{ error }}</p>
<button class="btn-secondary" @click="generateLink">Retry</button>
</div>
<!-- Success state with QR code and link -->
<template v-else-if="linkUrl">
<p class="reg-help">
Scan this QR code on the new device, or copy the link and open it there.
</p>
<QRCodeDisplay
:url="linkUrl"
:show-link="true"
@copied="onCopied"
/>
<p class="expiry-note" v-if="expiresAt">
This link expires {{ formatDate(expiresAt).toLowerCase() }}.
</p>
</template>
</div>
<div class="reg-actions">
<button class="btn-secondary" @click="$emit('close')">Close</button>
<button class="btn-primary" @click="copy">Copy Link</button>
</div>
</div>
</div>
<div v-else-if="inline && url" class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2>
</div>
<div class="section-body">
<div class="device-link-section">
<div class="qr-container">
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
<p>{{ displayUrl }}</p>
</a>
<p class="reg-help">
<span v-if="userName">The user should open this link on the device where they want to register.</span>
<span v-else>Open this link on the device you wish to connect with.</span>
<br><small>{{ expirationMessage }}</small>
</p>
</div>
</div>
<div class="button-row" style="margin-top:1rem;">
<button class="btn-primary" @click="copy">Copy Link</button>
<button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed, nextTick } from 'vue'
import QRCode from 'qrcode/lib/browser'
import { ref, onMounted } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/utils/api'
import { formatDate } from '@/utils/helpers'
import { useAuthStore } from '@/stores/auth'
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
const authStore = useAuthStore()
const props = defineProps({
endpoint: { type: String, required: true },
autoCopy: { type: Boolean, default: true },
userName: { type: String, default: null },
inline: { type: Boolean, default: false },
showCloseInInline: { type: Boolean, default: false },
prefixCopyWithUserName: { type: Boolean, default: false }
userName: { type: String, default: '' }
})
const emit = defineEmits(['close','generated','copied'])
const emit = defineEmits(['close', 'copied'])
const url = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const loading = ref(true)
const error = ref(null)
const linkUrl = ref(null)
const expiresAt = ref(null)
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '')
async function generateLink() {
loading.value = true
error.value = null
linkUrl.value = null
expiresAt.value = null
const expirationMessage = computed(() => {
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.`
})
async function fetchLink() {
try {
const data = await apiJson(props.endpoint, { method: 'POST' })
url.value = data.url
expires.value = data.expires
emit('generated', { url: data.url, expires: data.expires })
await nextTick()
drawQR()
if (props.autoCopy) copy()
} catch (e) {
console.error('Failed to create link', e)
if (shouldShowErrorToast(e)) {
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
if (data.url) {
linkUrl.value = data.url
expiresAt.value = data.expires ? new Date(data.expires) : null
} else {
error.value = data.detail || 'Failed to generate link'
}
// Close the dialog on any error (auth cancelled, network error, etc.)
emit('close')
} catch (err) {
error.value = err.message || 'Failed to generate link'
} finally {
loading.value = false
}
}
async function drawQR() {
if (!url.value) return
await nextTick()
if (!qrCanvas.value) return
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => { if (err) console.error(err) })
function onCopied() {
emit('copied')
}
async function copy() {
if (!url.value) return
let text = url.value
if (props.prefixCopyWithUserName && props.userName) {
text = `${props.userName} ${text}`
}
try {
await navigator.clipboard.writeText(text)
emit('copied', text)
if (!props.inline) emit('close')
} catch (_) {
/* ignore */
}
}
onMounted(fetchLink)
watch(url, () => drawQR(), { flush: 'post' })
onMounted(() => {
generateLink()
})
</script>
<style scoped>
.icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; }
.icon-btn:hover { opacity:1; }
/* Minimal extra styling; main look comes from global styles */
.qr-link { text-decoration:none; color:inherit; }
.reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; }
.reg-title { margin:0; font-size:1.25rem; font-weight:600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); }
.qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; }
.qr-code { display:block; }
.reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; }
.reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; }
.registration-inline-block .qr-container { align-items:flex-start; }
.registration-inline-block .reg-help { text-align:left; }
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
.reg-help { margin: .5rem 0 .75rem; font-size: .85rem; line-height: 1.4; text-align: center; color: var(--color-text-muted); }
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1rem; }
.loading-state { display: flex; align-items: center; justify-content: center; gap: .5rem; padding: 2rem 0; color: var(--color-text-muted); }
.error-state { text-align: center; padding: 1rem 0; }
.error-message { color: var(--color-danger-text); margin-bottom: 1rem; }
.expiry-note { font-size: .75rem; color: var(--color-text-muted); text-align: center; margin-top: .75rem; }
</style>
@@ -1,237 +0,0 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="surface surface--tight">
<header class="view-header center">
<h1>📱 Remote Login</h1>
<p class="view-lede">{{ subtitleMessage }}</p>
</header>
<section class="section-block">
<div class="section-body center">
<!-- Loading/Initializing -->
<div v-if="initializing" class="loading-section">
<p>Connecting</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<div class="button-row center">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
<!-- Completed state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> {{ completedMessage }}</p>
<p class="help-text">You can now close this window.</p>
<div class="button-row center">
<button class="btn-secondary" @click="goHome">Go to Profile</button>
</div>
</div>
<!-- Ready to authenticate -->
<div v-else class="auth-section">
<p class="help-text">
Use your passkey on this device to log in on another device.
</p>
<div class="button-row center">
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Cancel</button>
<button class="btn-primary" :disabled="loading" @click="authenticate">
{{ loading ? 'Authenticating' : 'Authenticate with Passkey' }}
</button>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings, uiBasePath } from '@/utils/settings'
import { solvePoW } from '@/utils/pow'
const props = defineProps({
token: { type: String, required: true }
})
const emit = defineEmits(['back', 'completed'])
const status = reactive({ show: false, message: '', type: 'info' })
const initializing = ref(true)
const loading = ref(false)
const settings = ref(null)
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
let statusTimer = null
let ws = null
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing secure remote authentication…'
if (error.value) return 'This remote login link is no longer valid.'
if (completed.value) return 'Remote login successful!'
return 'Authenticate here to log in on another device.'
})
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
status.message = message
status.type = type
if (statusTimer) clearTimeout(statusTimer)
if (duration > 0) {
statusTimer = setTimeout(() => { status.show = false }, duration)
}
}
async function fetchSettings() {
try {
const data = await getSettings()
settings.value = data
if (data?.rp_name) document.title = `${data.rp_name} · Remote Login`
} catch (err) {
console.warn('Unable to load settings', err)
}
}
async function authenticate() {
if (loading.value || !props.token) return
loading.value = true
showMessage('Starting authentication…', 'info')
try {
// Connect to the remote auth completion WebSocket
const authHost = settings.value?.auth_host
const wsPath = `/auth/ws/remote-auth/complete/${encodeURIComponent(props.token)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// First message is PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const powStart = performance.now()
const nonces = await solvePoW(challenge, powChallenge.pow.work)
const powTime = performance.now() - powStart
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
ws.send_json({ pow: b64enc(nonces) })
}
// Receive authentication options
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Authentication failed: ${res.status}`)
}
// Perform WebAuthn authentication (options are in optionsJSON field)
const authResponse = await startAuthentication(res.optionsJSON || res)
ws.send_json(authResponse)
// Wait for confirmation
const result = await ws.receive_json()
if (result.status === 'success') {
completed.value = true
completedMessage.value = result.message || 'The other device is now logged in.'
showMessage('Authentication successful!', 'success', 3000)
emit('completed')
} else {
throw new Error(result.detail || 'Remote authentication failed')
}
} catch (err) {
console.error('Remote authentication error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication cancelled'
: (err.message || 'Authentication failed')
const cancelled = message === 'Passkey authentication cancelled'
showMessage(message, cancelled ? 'info' : 'error', 4000)
if (!cancelled) {
error.value = message
}
} finally {
loading.value = false
if (ws) {
ws.close()
ws = null
}
}
}
function goHome() {
const target = uiBasePath() || '/auth/'
window.location.href = target
}
onMounted(async () => {
await fetchSettings()
initializing.value = false
})
onUnmounted(() => {
if (ws) {
ws.close()
ws = null
}
})
</script>
<style scoped>
.center { text-align: center; }
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
main.view-root {
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.help-text {
color: var(--color-text-muted);
font-size: 0.95rem;
margin: 0.5rem 0 1rem;
}
.success-message {
font-size: 1.1rem;
font-weight: 500;
color: var(--color-success, #10b981);
margin-bottom: 0.5rem;
}
.error-message {
font-size: 0.95rem;
color: var(--color-error, #ef4444);
margin-bottom: 1rem;
}
.loading-section, .auth-section, .success-section, .error-section {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
@@ -1,430 +0,0 @@
<template>
<!-- Dialog mode (modal overlay) -->
<div v-if="!inline && active" class="dialog-overlay" @keydown.esc.prevent="cancel">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="remoteTitle">
<div class="reg-header-row">
<h2 id="remoteTitle" class="reg-title">📱 Login from Another Device</h2>
<button class="icon-btn" @click="cancel" aria-label="Close"></button>
</div>
<!-- QR/Link display while waiting -->
<div v-if="url && !completed && !error" class="device-link-section">
<div class="qr-container">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter this code on a device with your passkey:</p>
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
</div>
<div class="qr-divider"><span>or scan</span></div>
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
</div>
<div class="waiting-indicator">
<div class="spinner"></div>
<span>Waiting for authentication</span>
</div>
</div>
<!-- Success state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> Authenticated successfully!</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
</div>
<!-- Loading state -->
<div v-else class="loading-section">
<div class="spinner"></div>
<p>Generating remote login link</p>
</div>
<div class="reg-actions">
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
</div>
</div>
</div>
<!-- Inline mode (embedded in page) -->
<div v-else-if="inline && active" class="registration-inline-wrapper">
<div class="registration-inline-block section-block">
<div class="section-header">
<h2 class="inline-heading">📱 Login from Another Device</h2>
</div>
<div class="section-body">
<!-- QR/Link display while waiting -->
<div v-if="url && !completed && !error" class="device-link-section">
<div class="qr-container">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter this code on a device with your passkey:</p>
<div class="pairing-code" @click="copyCode">{{ displayCode }}</div>
</div>
<div class="qr-divider"><span>or scan</span></div>
<a :href="url" @click.prevent="copy" class="qr-link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
<p class="reg-help"><small>{{ expirationMessage }}</small></p>
</div>
<div class="waiting-indicator">
<div class="spinner"></div>
<span>Waiting for authentication</span>
</div>
</div>
<!-- Success state -->
<div v-else-if="completed" class="success-section">
<p class="success-message"> Authenticated successfully!</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
</div>
<!-- Loading state -->
<div v-else class="loading-section">
<div class="spinner"></div>
<p>Generating remote login link</p>
</div>
<div class="button-row" style="margin-top:1rem;">
<button v-if="url && !completed && !error" class="btn-primary" @click="copy">Copy Link</button>
<button v-if="!completed" class="btn-secondary" @click="cancel">Cancel</button>
<button v-if="error" class="btn-primary" @click="retry">Try Again</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode/lib/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { formatDate } from '@/utils/helpers'
import { solvePoW } from '@/utils/pow'
const props = defineProps({
active: { type: Boolean, default: false },
inline: { type: Boolean, default: false },
autoCopy: { type: Boolean, default: false }
})
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'close', 'copied'])
const url = ref(null)
const pairingCode = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const completed = ref(false)
const error = ref(null)
let ws = null
const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//, '') : '')
// Display pairing code as space-separated words for readability
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
const expirationMessage = computed(() => {
if (!expires.value) return ''
const timeStr = formatDate(expires.value)
return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr}.`
})
async function startRemoteAuth() {
error.value = null
completed.value = false
url.value = null
pairingCode.value = null
expires.value = null
try {
// Get auth_host from settings to construct proper WebSocket URL
const settings = await getSettings()
const authHost = settings?.auth_host
const wsPath = '/auth/ws/remote-auth/request'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// First message is PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const powStart = performance.now()
const nonces = await solvePoW(challenge, powChallenge.pow.work)
const powTime = performance.now() - powStart
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
ws.send_json({ pow: b64enc(nonces) })
}
// Receive the remote auth token, pairing code, and URL
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
}
url.value = res.url
pairingCode.value = res.pairing_code
expires.value = res.expires
await nextTick()
drawQR()
if (props.autoCopy) copy()
// Now wait for authentication
waitForAuth()
} catch (err) {
console.error('Remote auth error:', err)
error.value = err.message || 'Failed to start remote authentication'
emit('error', error.value)
cleanup()
}
}
async function waitForAuth() {
if (!ws) return
try {
const result = await ws.receive_json()
if (result.status === 'authenticated') {
completed.value = true
emit('authenticated', {
session_token: result.session_token,
user_uuid: result.user_uuid
})
} else if (result.status === 'expired' || result.status === 'cancelled') {
error.value = result.detail || 'Remote authentication was cancelled or expired'
emit('cancelled')
} else {
error.value = result.detail || 'Remote authentication failed'
emit('error', error.value)
}
} catch (err) {
if (!completed.value) {
error.value = err.message || 'Connection lost'
emit('error', error.value)
}
} finally {
cleanup()
}
}
function drawQR() {
if (!url.value) return
nextTick(() => {
if (!qrCanvas.value) return
QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, err => {
if (err) console.error('QR code error:', err)
})
})
}
async function copy() {
if (!url.value) return
try {
await navigator.clipboard.writeText(url.value)
emit('copied', url.value)
} catch (_) {
/* ignore */
}
}
async function copyCode() {
if (!pairingCode.value) return
try {
await navigator.clipboard.writeText(pairingCode.value)
} catch (_) {
/* ignore */
}
}
function cancel() {
if (ws) {
try {
ws.send_json({ action: 'cancel' })
} catch (_) {
/* ignore */
}
}
cleanup()
emit('cancelled')
emit('close')
}
function retry() {
error.value = null
startRemoteAuth()
}
function cleanup() {
if (ws) {
try {
ws.close()
} catch (_) {
/* ignore */
}
ws = null
}
}
watch(() => props.active, (newVal) => {
if (newVal) {
startRemoteAuth()
} else {
cleanup()
// Reset state when deactivated
url.value = null
pairingCode.value = null
expires.value = null
completed.value = false
error.value = null
}
})
onMounted(() => {
if (props.active) {
startRemoteAuth()
}
})
onUnmounted(() => {
cleanup()
})
watch(url, () => drawQR(), { flush: 'post' })
</script>
<style scoped>
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.qr-link { text-decoration: none; color: inherit; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
.reg-title { margin: 0; font-size: 1.25rem; font-weight: 600; }
.device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width: 480px; width: 100%; box-shadow: 0 6px 28px rgba(0,0,0,.25); }
.qr-container { display: flex; flex-direction: column; align-items: center; gap: .5rem; }
.qr-code { display: block; }
.reg-help { margin-top: .5rem; margin-bottom: .75rem; font-size: .85rem; line-height: 1.25rem; text-align: center; }
.reg-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .25rem; }
.registration-inline-block .qr-container { align-items: flex-start; }
.registration-inline-block .reg-help { text-align: left; }
.pairing-code-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--color-surface-hover, rgba(0,0,0,0.05));
border-radius: var(--radius-sm, 4px);
width: 100%;
}
.pairing-label {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.pairing-code {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--color-primary);
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.15s;
text-transform: capitalize;
}
.pairing-code:hover {
background: var(--color-surface-hover, rgba(0,0,0,0.1));
}
.qr-divider {
display: flex;
align-items: center;
width: 100%;
margin: 0.5rem 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.qr-divider::before,
.qr-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border);
}
.qr-divider span {
padding: 0 0.75rem;
}
.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--color-surface-hover, rgba(0,0,0,0.05));
border-radius: var(--radius-sm, 4px);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.loading-section .spinner {
width: 32px;
height: 32px;
border-width: 3px;
}
.success-section, .error-section {
padding: 1.5rem;
text-align: center;
}
.success-message {
font-size: 1.1rem;
font-weight: 500;
color: var(--color-success, #10b981);
}
.error-message {
font-size: 0.95rem;
color: var(--color-error, #ef4444);
}
</style>
@@ -0,0 +1,969 @@
<template>
<div class="pairing-entry">
<form @submit.prevent="submitCode" class="pairing-form">
<!-- Code input (only shown in pairing mode, not token mode) -->
<div v-if="!deviceInfo && !props.token" class="input-row">
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }">
<!-- Visual slot-machine display overlay -->
<div class="slot-machine" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError }" aria-hidden="true">
<div v-for="(word, index) in displayWords" :key="index" class="slot-reel" :class="{ 'invalid-word': word.invalid, 'empty': !word.text && !word.typedPrefix }">
<div class="slot-word">
<template v-if="word.typedPrefix">
<span class="typed-prefix">{{ word.typedPrefix }}</span><span class="hint-suffix">{{ word.hintSuffix }}</span>
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
</template>
<template v-else-if="word.text">
{{ word.text }}
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': word.cursorCharIndex, '--word-len': word.wordLen }"></span>
</template>
<template v-else>
<span v-if="word.hasCursor" class="cursor-overlay" :style="{ '--cursor-pos': 0, '--word-len': 0 }"></span>
</template>
</div>
</div>
</div>
<!-- Hidden input for actual text entry -->
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input hidden-input"
@input="handleInput"
@keydown="deferUpdateCursor"
@mouseup="updateCursorPos"
@focus="isFocused = true"
@blur="isFocused = false"
/>
</div>
<!-- Processing status beside input -->
<div v-if="processingStatus" class="processing-status">
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
<span class="processing-spinner-small"></span>
</div>
</div>
<!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info">
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
<div class="button-row" style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
<button
type="button"
class="btn-secondary"
:disabled="loading"
@click="deny"
style="flex: 1;"
>
Deny
</button>
<button
ref="submitBtnRef"
type="submit"
:disabled="loading"
class="btn-primary"
style="flex: 1;"
>
{{ loading ? 'Authenticating…' : 'Authorize' }}
</button>
</div>
</div>
<p v-if="error && !deviceInfo" class="error-message">{{ error }}</p>
</form>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter three words' },
action: { type: String, default: 'login' }, // 'login' or 'register'
token: { type: String, default: null } // 5-word token for direct auth (skips code entry)
})
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
// State
const loading = ref(false)
const error = ref(null)
const settings = ref(null)
let ws = null
let authStore = null
// Try to get authStore (might fail if Pinia not installed in this app instance)
try { authStore = useAuthStore() } catch (e) { /* ignore */ }
const inputRef = ref(null)
const submitBtnRef = ref(null)
const code = ref('')
const isProcessing = ref(false)
const processingStatus = ref('')
const deviceInfo = ref(null)
const autocompleteHint = ref('')
// Watch deviceInfo and emit visibility change
watch(deviceInfo, (newVal) => {
emit('deviceInfoVisible', !!newVal)
})
const hasInvalidWord = ref(false)
const serverError = ref(false)
const cursorPos = ref(0)
const isFocused = ref(false)
let wsConnecting = false
let currentChallenge = null
let currentWork = null
let powPromise = null
let powSolution = null
let lookupTimeout = null
let lastLookedUpCode = null
// --- Helpers ---
function showMessage(message, type = 'info', duration = 3000) {
if (authStore) {
authStore.showMessage(message, type, duration)
}
}
async function fetchSettings() {
try {
const data = await getSettings()
settings.value = data
} catch (err) {
console.warn('Unable to load settings', err)
}
}
// --- Input Mode Logic ---
function getWordAtCursor(input, cursor) {
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
let start = cursor, end = cursor
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) start--
while (end < input.length && /[a-zA-Z]/.test(input[end])) end++
return { word: input.slice(start, end), start, end }
}
function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
function countCompleteWords(input) {
const endsWithSeparator = /[.\s]$/.test(input)
const words = getWords(input)
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
}
function analyzeWords(input) {
if (!input) return { valid: true, segments: [] }
const segments = []
const endsWithSeparator = /[.\s]$/.test(input)
let match, regex = /([a-zA-Z]+)|([.\s]+)/g
while ((match = regex.exec(input)) !== null) {
if (match[1]) segments.push({ text: match[1], isWord: true, start: match.index })
else if (match[2]) segments.push({ text: match[2], isWord: false, start: match.index })
}
const words = segments.filter(s => s.isWord)
let allValid = true
words.forEach((wordSeg, idx) => {
const isLastWord = idx === words.length - 1
const word = wordSeg.text.toLowerCase()
if (isLastWord && !endsWithSeparator) wordSeg.invalid = !isValidPrefix(word)
else wordSeg.invalid = !isValidWord(word)
if (wordSeg.invalid) allValid = false
})
return { valid: allValid, segments }
}
const coloredSegments = computed(() => {
const { segments } = analyzeWords(code.value)
return segments.map(s => ({ text: s.text, invalid: s.invalid || false }))
})
function checkWordsValidity(input) { return analyzeWords(input).valid }
function allWordsValid(input) { return getWords(input).length > 0 && getWords(input).every(w => isValidWord(w)) }
// Get the current partial word being typed (not yet a complete word)
function getCurrentPartialWord(input) {
const endsWithSeparator = /[.\s]$/.test(input)
if (endsWithSeparator) return ''
const match = input.match(/[a-zA-Z]+$/)
return match ? match[0].toLowerCase() : ''
}
// Calculate cursor position in the normalized display (wordIndex, charIndex within word)
// Returns { wordIndex: number, charIndex: number } where charIndex is position within the word text
function calcDisplayCursor(input, rawCursorPos) {
if (!input || rawCursorPos === 0) {
return { wordIndex: 0, charIndex: 0 }
}
// Parse input to find word boundaries
const beforeCursor = input.slice(0, rawCursorPos)
const wordMatches = [...beforeCursor.matchAll(/[a-zA-Z]+/g)]
// Check if cursor is in whitespace after words
const endsWithSeparator = /[.\s]$/.test(beforeCursor)
if (wordMatches.length === 0) {
// No words before cursor, cursor is at start of first word
return { wordIndex: 0, charIndex: 0 }
}
const lastMatch = wordMatches[wordMatches.length - 1]
const lastMatchEnd = lastMatch.index + lastMatch[0].length
if (endsWithSeparator || rawCursorPos > lastMatchEnd) {
// Cursor is after the last word (in whitespace), so it's at start of next word
return { wordIndex: Math.min(wordMatches.length, 2), charIndex: 0 }
}
// Cursor is within the last word
const charIndex = rawCursorPos - lastMatch.index
return { wordIndex: wordMatches.length - 1, charIndex: charIndex }
}
// Compute display words for slot-machine overlay (always 3 slots)
const displayWords = computed(() => {
const words = getWords(code.value)
const result = []
// Get analysis for validation
const { segments } = analyzeWords(code.value)
const wordSegments = segments.filter(s => s.isWord)
// Get current partial word and autocomplete hint
const partialWord = getCurrentPartialWord(code.value)
const hint = autocompleteHint.value
const endsWithSeparator = /[.\s]$/.test(code.value)
// Calculate where cursor should be displayed
const cursor = calcDisplayCursor(code.value, cursorPos.value)
// Always show exactly 3 slots
for (let i = 0; i < 3; i++) {
const isCursorSlot = cursor.wordIndex === i
if (i < words.length) {
const word = words[i].toLowerCase()
const isInvalid = wordSegments[i]?.invalid || false
const isLastWord = i === words.length - 1
if (isLastWord && !endsWithSeparator && hint && partialWord) {
// Show typed prefix + hint suffix in the same slot
// Total visible length is the full hint word
const totalLen = hint.length
result.push({
text: '',
typedPrefix: partialWord,
hintSuffix: hint.slice(partialWord.length),
invalid: isInvalid,
hasCursor: isCursorSlot,
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
wordLen: totalLen
})
} else {
// Complete word - show cursor at appropriate position
result.push({
text: word,
invalid: isInvalid,
hasCursor: isCursorSlot,
cursorCharIndex: isCursorSlot ? cursor.charIndex : -1,
wordLen: word.length
})
}
} else {
// Empty slot
result.push({
text: '',
invalid: false,
hasCursor: isCursorSlot,
cursorCharIndex: 0,
wordLen: 0
})
}
}
return result
})
const hasThreeValidWords = computed(() => {
const words = getWords(code.value)
return words.length === 3 && words.every(w => isValidWord(w))
})
function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
function startPowSolving() {
if (!currentChallenge || powPromise) return
const challenge = b64dec(currentChallenge)
powPromise = solvePoW(challenge, currentWork).then(solution => {
powSolution = solution
powPromise = null
})
}
async function getPowSolution() {
if (powSolution) { const s = powSolution; powSolution = null; return s }
if (powPromise) { await powPromise; const s = powSolution; powSolution = null; return s }
if (!currentChallenge) throw new Error('No PoW challenge available')
const challenge = b64dec(currentChallenge)
return await solvePoW(challenge, currentWork)
}
function updateChallenge(pow) {
if (pow?.challenge) {
currentChallenge = pow.challenge
currentWork = pow.work
powSolution = null
powPromise = null
startPowSolving()
}
}
async function ensureConnection() {
if (ws || wsConnecting) return
wsConnecting = true
try {
const authHost = settings.value?.auth_host
const wsPath = '/auth/ws/remote-auth/pair'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
const msg = await ws.receive_json()
if (msg.status && msg.detail) throw new Error(msg.detail)
if (!msg.pow?.challenge) throw new Error('Server did not send PoW challenge')
updateChallenge(msg.pow)
} catch (err) {
console.error('WebSocket connection error:', err)
ws = null
throw err
} finally {
wsConnecting = false
}
}
// Defer cursor position update to after browser processes the key
function deferUpdateCursor(event) {
// Handle Tab/Space for autocomplete immediately
if (event.key === 'Tab' || event.key === ' ') {
handleKeydown(event)
return
}
// Defer cursor update to next tick
setTimeout(updateCursorPos, 0)
}
// Update cursor position from input
function updateCursorPos() {
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
}
function updateAutocomplete() {
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
const completeWordCount = countCompleteWords(code.value)
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
autocompleteHint.value = ''
return
}
const match = getUniqueMatch(word.toLowerCase())
if (match && match !== word.toLowerCase()) autocompleteHint.value = match
else autocompleteHint.value = ''
}
function applyAutocomplete() {
if (!autocompleteHint.value) return false
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return false
const before = code.value.slice(0, start)
const wordsBefore = getWords(before).length
const isThirdWord = wordsBefore === 2
const suffix = isThirdWord ? '' : ' '
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + suffix + after.trimStart()
const newPos = start + autocompleteHint.value.length + suffix.length
nextTick(() => {
inputRef.value?.setSelectionRange(newPos, newPos)
cursorPos.value = newPos
})
autocompleteHint.value = ''
return true
}
// Try to split concatenated words (e.g., "alienalien" -> "alien alien")
function trySplitWords(input) {
// Only process if there's a continuous string of letters at the end
const match = input.match(/^(.*?)([a-zA-Z]+)$/)
if (!match) return input
const prefix = match[1] // Everything before the letter sequence
const letters = match[2].toLowerCase()
// Try to find valid word boundaries in the letter sequence
const foundWords = []
let remaining = letters
while (remaining.length > 0) {
let foundWord = null
// Try to find the longest valid word from the start
for (let len = Math.min(remaining.length, 6); len >= 3; len--) {
const candidate = remaining.slice(0, len)
if (isValidWord(candidate)) {
foundWord = candidate
break
}
}
if (foundWord) {
foundWords.push(foundWord)
remaining = remaining.slice(foundWord.length)
// Stop after 3 words
if (foundWords.length >= 3) {
remaining = ''
break
}
} else {
// No valid word found, keep the remaining as-is
foundWords.push(remaining)
break
}
}
// Only return split version if we found at least one complete word
// and there's a clear boundary (more than one segment, or the segment is a complete word)
if (foundWords.length > 1 || (foundWords.length === 1 && isValidWord(foundWords[0]) && remaining === '')) {
return prefix + foundWords.join(' ')
}
return input
}
function handleInput() {
// Immediately update cursor position
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
// First, try to auto-split concatenated words
const splitCode = trySplitWords(code.value)
if (splitCode !== code.value) {
code.value = splitCode
nextTick(() => {
const newLen = splitCode.length
inputRef.value?.setSelectionRange(newLen, newLen)
cursorPos.value = newLen
})
}
const words = getWords(code.value)
if (words.length >= 3) {
const normalized = words.slice(0, 3).join(' ')
if (code.value !== normalized) {
const cursorWasAtEnd = cursorPos.value >= code.value.length
code.value = normalized
if (cursorWasAtEnd) {
nextTick(() => {
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
cursorPos.value = normalized.length
})
}
}
}
updateAutocomplete()
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
deviceInfo.value = null
error.value = null
serverError.value = false
hasInvalidWord.value = !checkWordsValidity(code.value)
const currentWords = getWords(code.value)
if (currentWords.length >= 1 && !ws && !wsConnecting) ensureConnection()
if (currentWords.length === 3) {
if (!allWordsValid(code.value)) return
lookupTimeout = setTimeout(() => { lookupDeviceInfo() }, 150)
}
}
async function lookupDeviceInfo() {
if (isProcessing.value || loading.value) return
if (!hasThreeValidWords.value) return
const normalizedCode = normalizeCode(code.value)
if (normalizedCode === lastLookedUpCode && deviceInfo.value) return
isProcessing.value = true
processingStatus.value = 'pow'
error.value = null
serverError.value = false
try {
await ensureConnection()
if (!ws) throw new Error('Failed to connect')
const solution = await getPowSolution()
const powB64 = b64enc(solution)
const currentCode = normalizeCode(code.value)
if (!hasThreeValidWords.value) return
processingStatus.value = 'server'
ws.send_json({ code: currentCode, pow: powB64 })
const res = await ws.receive_json()
updateChallenge(res.pow)
if (typeof res.status === 'number' && res.status >= 400) {
error.value = res.detail || 'Request failed'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
return
}
if (res.status === 'found' && res.host) {
code.value = currentCode.replace(/\./g, ' ')
deviceInfo.value = {
host: res.host,
user_agent_pretty: res.user_agent_pretty,
client_ip: res.client_ip,
action: res.action || 'login'
}
lastLookedUpCode = currentCode
nextTick(() => { submitBtnRef.value?.focus() })
} else {
error.value = 'Unexpected response from server'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
}
} catch (err) {
console.error('Lookup error:', err)
error.value = err.message || 'Lookup failed'
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
if (ws) { ws.close(); ws = null }
} finally {
isProcessing.value = false
processingStatus.value = ''
}
}
function handleKeydown(event) {
if (event.key === 'Tab') {
if (autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) { event.preventDefault(); handleInput(); return }
}
if (code.value.trim()) event.preventDefault()
return
}
if (event.key === ' ' && autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) { event.preventDefault(); handleInput() }
}
}
async function submitCode() {
if (!deviceInfo.value || loading.value) return
loading.value = true
error.value = null
try {
if (!ws) await ensureConnection()
if (!ws) throw new Error('Failed to connect')
const solution = await getPowSolution()
const powB64 = b64enc(solution)
ws.send_json({ authenticate: true, pow: powB64 })
const res = await ws.receive_json()
if (typeof res.status === 'number' && res.status >= 400) throw new Error(res.detail || 'Authentication failed')
if (!res.optionsJSON) throw new Error(res.detail || 'Failed to get authentication options')
const authResponse = await startAuthentication(res.optionsJSON)
ws.send_json(authResponse)
const result = await ws.receive_json()
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
if (result.status === 'success') {
showMessage('Device authenticated successfully!', 'success', 3000)
emit('completed')
reset()
} else {
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: (err.message || 'Authentication failed')
error.value = message
// Don't show toast - error is shown in dialog
emit('error', message)
} finally {
loading.value = false
if (ws) { ws.close(); ws = null }
}
}
async function authenticateWithToken() {
if (!props.token || loading.value) return
loading.value = true
error.value = null
try {
const authHost = settings.value?.auth_host
const wsPath = `/auth/ws/remote-auth/pair`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// Receive PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
// Send the 5-word token instead of 3-word code
ws.send_json({ code: props.token, pow: b64enc(nonces) })
}
// Receive device info
const deviceRes = await ws.receive_json()
if (typeof deviceRes.status === 'number' && deviceRes.status >= 400) {
throw new Error(deviceRes.detail || 'This link is no longer valid')
}
if (deviceRes.status !== 'found') {
throw new Error('This link is no longer valid')
}
// Now authenticate
const solution = await solvePoW(b64dec(deviceRes.pow.challenge), deviceRes.pow.work)
ws.send_json({ authenticate: true, pow: b64enc(solution) })
const res = await ws.receive_json()
if (typeof res.status === 'number' && res.status >= 400) throw new Error(res.detail || 'Authentication failed')
if (!res.optionsJSON) throw new Error(res.detail || 'Failed to get authentication options')
const authResponse = await startAuthentication(res.optionsJSON)
ws.send_json(authResponse)
const result = await ws.receive_json()
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed')
if (result.status === 'success') {
showMessage('Device authenticated successfully!', 'success', 3000)
emit('completed')
reset()
} else {
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Token authentication error:', err)
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: (err.message || 'Authentication failed')
error.value = message
emit('error', message)
} finally {
loading.value = false
if (ws) { ws.close(); ws = null }
}
}
async function deny() {
// Send deny message to server before closing websocket
if (ws) {
try {
ws.send_json({ deny: true })
// Give the server a moment to process the denial
await new Promise(resolve => setTimeout(resolve, 100))
} catch (e) {
console.error('Error sending deny message:', e)
}
ws.close()
ws = null
}
// In token mode (standalone link), try to close the window
if (props.token) {
try {
window.close()
} catch (e) {
// If we can't close the window, just reset
reset()
}
} else {
// In pairing mode, just reset to initial state
reset()
}
}
function reset() {
code.value = ''
error.value = null
serverError.value = false
deviceInfo.value = null
isProcessing.value = false
processingStatus.value = ''
autocompleteHint.value = ''
hasInvalidWord.value = false
lastLookedUpCode = null
if (ws) { ws.close(); ws = null }
currentChallenge = null
currentWork = null
powPromise = null
powSolution = null
}
// --- Lifecycle ---
onMounted(async () => {
await fetchSettings()
inputRef.value?.focus()
// Initialize cursor position
nextTick(() => {
cursorPos.value = inputRef.value?.selectionStart ?? 0
})
})
onUnmounted(() => {
if (lookupTimeout) { clearTimeout(lookupTimeout); lookupTimeout = null }
if (ws) { ws.close(); ws = null }
})
defineExpose({ reset, deny, code, handleInput, authenticateWithToken, loading, error })
</script>
<style scoped>
/* Input Mode Styles */
.pairing-entry {
display: flex;
flex-direction: column;
gap: 1rem;
}
.pairing-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
width: 280px;
max-width: 100%;
}
/* Slot machine visual display (matches RemoteAuthInline) */
.slot-machine {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
gap: 0;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
box-sizing: border-box;
z-index: 1;
}
.slot-machine.has-error {
border-color: var(--color-error, #ef4444);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
.slot-machine.is-complete {
border-color: var(--color-success, #10b981);
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1 1 33.333%;
min-width: 0;
height: 1.8em;
overflow: visible;
position: relative;
border-radius: 3px;
}
.slot-reel:not(:last-child) {
margin-right: 0.5rem;
}
.slot-word {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.05em;
text-align: center;
width: 100%;
color: var(--color-text);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.slot-word .typed-prefix {
color: var(--color-text);
}
.slot-word .hint-suffix {
color: var(--color-text-muted);
opacity: 0.6;
}
.cursor-overlay {
position: absolute;
width: 2px;
height: 1.2em;
background: var(--color-text);
animation: none;
pointer-events: none;
/* Position based on character index - calculate from center of slot */
left: calc(50% + (var(--cursor-pos) - var(--word-len, 0) / 2) * 0.65em);
transform: translateX(-1px);
opacity: 0;
}
.input-wrapper.focused .cursor-overlay {
opacity: 1;
animation: cursorBlink 1s ease-in-out infinite;
}
@keyframes cursorBlink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
.slot-reel.invalid-word .slot-word {
color: var(--color-error, #ef4444);
}
.slot-reel.invalid-word .slot-word .typed-prefix {
color: var(--color-error, #ef4444);
}
.slot-reel.invalid-word .cursor-overlay {
background: var(--color-error, #ef4444);
}
.slot-reel.empty .slot-word {
color: var(--color-text-muted);
}
/* Hidden input - keeps focus and handles keyboard input */
.pairing-input {
flex: 1;
width: 100%;
height: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-family: inherit;
border: 1px solid transparent;
border-radius: var(--radius-sm, 6px);
background: transparent;
color: transparent;
caret-color: transparent;
outline: none;
box-sizing: border-box;
position: relative;
z-index: 0;
}
.pairing-input.hidden-input {
color: transparent;
caret-color: transparent;
}
.pairing-input:disabled {
cursor: not-allowed;
}
.pairing-input::placeholder {
color: transparent;
}
.processing-status {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.processing-icon {
font-size: 0.875rem;
}
.processing-spinner-small {
width: 12px;
height: 12px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.device-permit-text {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
}
.device-meta {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.error-message {
margin: 0;
font-size: 0.875rem;
color: var(--color-error, #ef4444);
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,692 @@
<template>
<div class="remote-auth-inline">
<!-- Success state -->
<div v-if="completed" class="success-section">
<p class="success-message"> {{ successMessage }}</p>
</div>
<!-- Error state -->
<div v-else-if="error" class="error-section">
<p class="error-message">{{ error }}</p>
<button class="btn-primary" @click="retry" style="margin-top: 0.75rem;">Try Again</button>
</div>
<!-- Connecting phase -->
<div v-else-if="phase === 'connecting'" class="auth-display">
<div class="auth-content">
<div class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine" aria-hidden="true">
<div class="slot-reel" v-for="(word, index) in animatedWords" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
<div class="qr-section">
<div class="qr-code qr-placeholder"></div>
</div>
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>Generating secure link</span>
</div>
</div>
<!-- Waiting/Authenticating phase - show codes and QR -->
<div v-else class="auth-display">
<div class="auth-content">
<div v-if="pairingCode" class="pairing-code-section">
<p class="pairing-label">Enter the code words:</p>
<div class="slot-machine stopped">
<div class="slot-reel" v-for="(word, index) in displayCode.split(' ')" :key="index">
<div class="slot-word">{{ word }}</div>
</div>
</div>
<p class="site-url">{{ siteUrlDisplay }}</p>
</div>
<div class="qr-section">
<a :href="url" @click.prevent="copyLink" class="qr-link" title="Click to copy link">
<canvas ref="qrCanvas" class="qr-code"></canvas>
</a>
</div>
</div>
<div v-if="showCopyToast" class="copy-toast">
Link copied to clipboard
</div>
<div class="waiting-indicator">
<div class="spinner-small"></div>
<span>{{ waitingMessage }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode/lib/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { solvePoW } from '@/utils/pow'
import { words } from '@/utils/wordlist'
const props = defineProps({
active: { type: Boolean, default: false }
})
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
const url = ref(null)
const pairingCode = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const completed = ref(false)
const error = ref(null)
const phase = ref('connecting')
const settings = ref(null)
const showCopyToast = ref(false)
const animatedWords = ref(['', '', ''])
let ws = null
let copyToastTimer = null
let wordAnimationTimer = null
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
const siteUrlDisplay = computed(() => {
if (!settings.value) return ''
const authSiteUrl = settings.value.auth_site_url || `${location.protocol}//${location.host}/auth/`
// Remove the protocol and any trailing slash
const withoutProtocol = authSiteUrl.replace(/^https?:\/\//, '')
return withoutProtocol.endsWith('/') ? withoutProtocol.slice(0, -1) : withoutProtocol
})
const waitingMessage = computed(() => {
return phase.value === 'authenticating'
? 'Complete on another device…'
: 'Waiting for authentication…'
})
const successMessage = computed(() => 'Authenticated successfully!')
function getRandomWord() {
return words[Math.floor(Math.random() * words.length)]
}
function startWordAnimation() {
// Initialize with random words
animatedWords.value = [getRandomWord(), getRandomWord(), getRandomWord()]
let updateCount = 0
const maxUpdates = 20 // Number of cycles before stopping
// Different intervals for each slot to spin independently
const intervals = [
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[0] = getRandomWord()
animatedWords.value = newWords
}, 140),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[1] = getRandomWord()
animatedWords.value = newWords
}, 170),
setInterval(() => {
const newWords = [...animatedWords.value]
newWords[2] = getRandomWord()
animatedWords.value = newWords
}, 200)
]
wordAnimationTimer = intervals
// Stop all after max updates
setTimeout(() => {
intervals.forEach(interval => clearInterval(interval))
wordAnimationTimer = null
}, maxUpdates * 170) // Average interval time
}
function stopWordAnimation() {
if (wordAnimationTimer) {
if (Array.isArray(wordAnimationTimer)) {
wordAnimationTimer.forEach(interval => clearInterval(interval))
} else {
clearInterval(wordAnimationTimer)
}
wordAnimationTimer = null
}
}
async function startRemoteAuth() {
error.value = null
completed.value = false
url.value = null
pairingCode.value = null
expires.value = null
phase.value = 'connecting'
// Start word animation
startWordAnimation()
try {
settings.value = await getSettings()
const authHost = settings.value?.auth_host
const wsPath = '/auth/ws/remote-auth/request'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// PoW challenge
const powChallenge = await ws.receive_json()
if (powChallenge.pow) {
const challenge = b64dec(powChallenge.pow.challenge)
const nonces = await solvePoW(challenge, powChallenge.pow.work)
ws.send_json({ pow: b64enc(nonces), action: 'login' })
}
// Receive the remote auth token and pairing code
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${res.status}`)
}
// Build the URL in frontend using auth_site_url from settings
const authSiteUrl = settings.value?.auth_site_url || `${location.protocol}//${location.host}/auth/`
url.value = authSiteUrl + res.token
pairingCode.value = res.pairing_code
expires.value = res.expires
// Stop word animation
stopWordAnimation()
phase.value = 'waiting'
await nextTick()
drawQR()
// Wait for authentication
while (true) {
const msg = await ws.receive_json()
if (msg.status === 'locked') {
// Someone has entered the code and is authenticating
phase.value = 'authenticating'
} else if (msg.status === 'paired') {
// Legacy/compatibility: Device paired, now authenticating
phase.value = 'authenticating'
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
throw new Error('Access denied')
} else if (msg.status === 'completed') {
// Registration flow
if (msg.reset_token) {
completed.value = true
emit('register', msg.reset_token)
}
break
} else if (msg.status === 'error' || msg.detail) {
throw new Error(msg.detail || 'Remote authentication failed')
}
}
} catch (err) {
console.error('Remote authentication error:', err)
const message = err.message || 'Authentication failed'
error.value = message
emit('error', message)
} finally {
if (ws) {
ws.close()
ws = null
}
}
}
function drawQR() {
if (!url.value || !qrCanvas.value) return
// Use a fixed scale that works well for most URLs
// This allows CSS to control the actual display size
const scale = 6
QRCode.toCanvas(qrCanvas.value, url.value, {
scale: scale,
margin: 0,
color: {
dark: '#000000',
light: '#FFFFFF'
}
}, err => {
if (err) console.error('QR code error:', err)
})
qrCanvas.value.removeAttribute('style')
}
async function copyLink() {
if (!url.value) return
try {
await navigator.clipboard.writeText(url.value)
showCopyToast.value = true
if (copyToastTimer) clearTimeout(copyToastTimer)
copyToastTimer = setTimeout(() => {
showCopyToast.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy link:', err)
}
}
function retry() {
startRemoteAuth()
}
function cancel() {
if (ws) {
ws.close()
ws = null
}
emit('cancelled')
}
watch(() => props.active, (newVal) => {
if (newVal && !url.value && !error.value && !completed.value) {
startRemoteAuth()
}
})
onMounted(() => {
if (props.active) {
startRemoteAuth()
}
})
onUnmounted(() => {
if (ws) {
ws.close()
ws = null
}
if (copyToastTimer) {
clearTimeout(copyToastTimer)
}
stopWordAnimation()
})
defineExpose({ retry, cancel })
</script>
<style scoped>
.remote-auth-inline {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.loading-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem 1rem;
min-height: 180px;
justify-content: center;
}
.loading-section p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auth-display {
display: flex;
flex-direction: column;
gap: 1.25rem;
width: 100%;
min-height: 180px;
}
.auth-content {
display: flex;
gap: 2rem;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
}
.loading-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem;
}
.loading-placeholder p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.95rem;
}
.pairing-code-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 280px;
max-width: 100%;
}
.pairing-label {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
font-weight: 500;
text-align: center;
}
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 2px solid var(--color-border);
border-radius: var(--radius-sm, 6px);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
display: flex;
align-items: center;
user-select: none;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
}
.slot-reel {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
height: 1.8em;
overflow: hidden;
position: relative;
background: var(--color-surface, rgba(255, 255, 255, 0.5));
border-radius: 3px;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) {
animation: slotSpin 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) {
animation: slotSpin 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) {
animation: slotSpin 0.20s ease-in-out infinite;
}
.slot-word {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.05em;
text-align: center;
width: 100%;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(1) .slot-word {
animation: wordRoll 0.14s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(2) .slot-word {
animation: wordRoll 0.17s ease-in-out infinite;
}
.slot-machine:not(.stopped) .slot-reel:nth-child(3) .slot-word {
animation: wordRoll 0.20s ease-in-out infinite;
}
@keyframes slotSpin {
0% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
50% {
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.2);
}
100% {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
@keyframes wordRoll {
0% {
transform: translateY(-30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
25% {
transform: translateY(-10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
50% {
transform: translateY(0) scale(1);
opacity: 1;
filter: blur(0);
}
75% {
transform: translateY(10%) scale(0.95);
opacity: 0.6;
filter: blur(1px);
}
100% {
transform: translateY(30%) scale(0.9);
opacity: 0.4;
filter: blur(1.5px);
}
}
.site-url {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
opacity: 0.8;
}
.qr-section {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
width: 180px;
}
.qr-label {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
font-weight: 500;
}
.qr-link {
display: block;
cursor: pointer;
transition: all 0.15s;
width: 100%;
max-width: 180px;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
line-height: 0;
}
.qr-link:hover {
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.qr-link:active {
transform: scale(0.98);
}
.qr-code {
display: block;
width: 100%;
height: auto;
max-width: 100%;
object-fit: contain;
aspect-ratio: 1;
border-radius: var(--radius-sm, 6px);
overflow: hidden;
background: #ffffff;
}
.qr-placeholder {
position: relative;
display: flex;
align-items: center;
justify-content: center;
animation: qrPulse 2s ease-in-out infinite;
}
@keyframes qrPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.02));
border-radius: var(--radius-sm, 6px);
font-size: 0.875rem;
color: var(--color-text-muted);
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.success-section {
padding: 1rem;
text-align: center;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.success-message {
margin: 0;
font-size: 1rem;
color: var(--color-success, #10b981);
font-weight: 500;
}
.error-section {
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 180px;
}
.error-message {
margin: 0;
font-size: 0.95rem;
color: var(--color-error, #ef4444);
}
.copy-toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.75rem 1.5rem;
background: var(--color-success, #10b981);
color: white;
border-radius: var(--radius-sm, 6px);
font-size: 0.9rem;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(1rem);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.auth-content {
gap: 1.5rem;
flex-direction: column;
align-items: center;
}
.pairing-code-section,
.qr-section {
width: 100%;
max-width: 280px;
}
.qr-section {
max-width: 180px;
}
}
@media (max-width: 480px) {
.pairing-code {
font-size: 1.1rem;
padding: 0.75rem 0.875rem;
}
.pairing-code-section {
width: 100%;
max-width: 100%;
}
}
</style>
+139 -48
View File
@@ -11,60 +11,88 @@
<header class="view-header center">
<h1>{{ headingTitle }}</h1>
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
<p class="view-lede">{{ headerMessage }}</p>
<p class="view-lede" v-html="headerMessage"></p>
</header>
<section class="section-block">
<div class="section-body center">
<div class="button-row center">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
:is-authenticated="isAuthenticated"
:authenticate="authenticateUser"
:logout="logoutUser"
:mode="mode"
:start-remote-auth="startRemoteAuth">
<!-- Default actions -->
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
<!-- Local passkey authentication view -->
<div v-if="authView === 'local'" class="auth-view">
<div class="button-row center">
<slot name="actions"
:loading="loading"
:can-authenticate="canAuthenticate"
:is-authenticated="isAuthenticated"
:authenticate="authenticateUser"
:logout="logoutUser"
:mode="mode">
<!-- Default actions -->
<button class="btn-secondary" :disabled="loading" @click="$emit('back')">Back</button>
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? (mode === 'reauth' ? 'Verifying' : 'Signing in') : (mode === 'reauth' ? 'Verify' : 'Login') }}
</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot>
</div>
</div>
<!-- Remote authentication view (request new remote auth) -->
<div v-else-if="authView === 'remote'" class="auth-view">
<RemoteAuthInline
:active="authView === 'remote'"
@authenticated="handleRemoteAuthenticated"
@register="handleRemoteRegistration"
@cancelled="switchToLocal"
@error="handleRemoteAuthError"
/>
</div>
<!-- Remote auth completion view (complete auth from link) -->
<div v-else-if="authView === 'complete'" class="auth-view">
<!-- Hidden RemoteAuth component for logic only -->
<RemoteAuth
ref="remoteAuthRef"
:token="remoteAuthToken"
@completed="handleRemoteAuthCompleted"
@error="handleRemoteAuthError"
@back="switchToLocal"
style="display: none;"
/>
<!-- Show error if any -->
<p v-if="remoteAuthRef?.error" class="error-message" style="margin-bottom: 1rem;">{{ remoteAuthRef.error }}</p>
<!-- Buttons in dialog style -->
<div class="button-row center">
<button class="btn-secondary" :disabled="remoteAuthRef?.loading" @click="remoteAuthRef?.deny()">Deny</button>
<button class="btn-primary" :disabled="remoteAuthRef?.loading" @click="remoteAuthRef?.authenticateWithToken()">
{{ remoteAuthRef?.loading ? 'Authenticating' : 'Authorize' }}
</button>
<button v-if="canAuthenticate && mode !== 'reauth'" class="btn-secondary" :disabled="loading" @click="startRemoteAuth">
Use Another Device
</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated && mode !== 'reauth'" class="btn-primary" :disabled="loading" @click="openProfile">Profile</button>
</slot>
</div>
</div>
</div>
</section>
</div>
</main>
<!-- Remote Auth Modal -->
<RemoteAuthLinkModal
:active="showRemoteAuth"
@authenticated="handleRemoteAuthenticated"
@cancelled="showRemoteAuth = false"
@close="showRemoteAuth = false"
@error="handleRemoteAuthError"
/>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthLinkModal from '@/components/RemoteAuthLinkModal.vue'
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
const props = defineProps({
mode: {
type: String,
default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value)
},
remoteAuthToken: {
type: String,
default: null
}
})
@@ -76,22 +104,23 @@ const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const showRemoteAuth = ref(false)
const authView = ref('local') // 'local', 'remote', or 'complete'
const remoteAuthRef = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => {
if (initializing.value) return false
// In reauth mode, allow authentication even if already authenticated
if (props.mode === 'reauth') return true
// In forbidden view (authenticated but lacking permissions), don't allow authentication
if (currentView.value === 'forbidden') return false
// In login view or initial state, allow authentication
return true
})
const headingTitle = computed(() => {
if (authView.value === 'complete') {
return `🔐 ${settings.value?.rp_name || location.origin}`
}
if (props.mode === 'reauth') {
return `🔐 Additional Authentication`
}
@@ -100,12 +129,21 @@ const headingTitle = computed(() => {
})
const headerMessage = computed(() => {
if (authView.value === 'complete') {
return 'Complete the login request from another device.'
}
if (props.mode === 'reauth') {
return 'Please verify your identity to continue with this action.'
}
if (currentView.value === 'forbidden') {
return 'You lack the required permissions.'
}
if (authView.value === 'remote') {
return 'Confirm from your other device. Or <a href="#" class="inline-link" data-action="local">this device</a>.'
}
if (canAuthenticate.value && props.mode !== 'reauth') {
return 'Please sign in with your passkey. Or use <a href="#" class="inline-link" data-action="remote">another device</a>.'
}
return 'Please sign in with your passkey.'
})
@@ -137,7 +175,6 @@ async function fetchSettings() {
async function fetchUserInfo() {
try {
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
// Determine view based on authentication status
if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden'
emit('forbidden', userInfo.value)
@@ -146,7 +183,6 @@ async function fetchUserInfo() {
}
} catch (error) {
console.error('Failed to load user info', error)
// For 401/403 just go to login, for other errors show message
if (error.status !== 401 && error.status !== 403) {
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
}
@@ -185,7 +221,6 @@ async function logoutUser() {
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
userInfo.value = null
// Switch to login view after logout
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
} catch (error) {
@@ -196,7 +231,6 @@ async function logoutUser() {
}
function openProfile() {
// Open profile in a new window with a specific name to reuse the same tab
const profileWindow = window.open('/auth/', 'passkey_auth_profile')
if (profileWindow) profileWindow.focus()
}
@@ -211,13 +245,15 @@ async function setSessionCookie(result) {
})
}
// Remote authentication from another device
function startRemoteAuth() {
showRemoteAuth.value = true
function switchToRemote() {
authView.value = 'remote'
}
function switchToLocal() {
authView.value = 'local'
}
async function handleRemoteAuthenticated(result) {
showRemoteAuth.value = false
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
@@ -230,15 +266,51 @@ async function handleRemoteAuthenticated(result) {
emit('authenticated', result)
}
function handleRemoteRegistration(token) {
showMessage('Registration approved! Redirecting...', 'success', 2000)
const basePath = uiBasePath() || '/auth/'
window.location.href = `${basePath}${token}`
}
function handleRemoteAuthError(errorMsg) {
showRemoteAuth.value = false
showMessage(errorMsg || 'Remote authentication failed', 'error', 4000)
// Error is already shown in the RemoteAuth component, don't show toast
}
function handleRemoteAuthCompleted() {
showMessage('The other device is now logged in!', 'success', 3000)
// Switch back to local view after completion
authView.value = 'local'
}
function handleHeaderLinkClick(event) {
const target = event.target
if (target.tagName === 'A' && target.classList.contains('inline-link')) {
event.preventDefault()
const action = target.dataset.action
if (action === 'remote') {
switchToRemote()
} else if (action === 'local') {
switchToLocal()
}
}
}
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
initializing.value = false
// If we have a remote auth token from the URL, switch to completion mode
if (props.remoteAuthToken) {
authView.value = 'complete'
}
// Add click handler for inline links
document.addEventListener('click', handleHeaderLinkClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleHeaderLinkClick)
})
defineExpose({
@@ -249,9 +321,8 @@ defineExpose({
</script>
<style scoped>
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
.button-row.center { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
.user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
/* Vertically center the restricted "dialog" surface in the viewport */
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
max-width: 520px;
@@ -261,4 +332,24 @@ main.view-root { min-height: 100vh; align-items: center; justify-content: center
flex-direction: column;
gap: 1.75rem;
}
.auth-view {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
}
.view-lede :deep(.inline-link) {
color: var(--color-primary);
text-decoration: none;
transition: opacity 0.15s;
font-weight: 400;
}
.view-lede :deep(.inline-link:hover) {
opacity: 0.8;
text-decoration: underline;
}
</style>
+53 -12
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="userLoaded" class="user-info">
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<h3 class="user-name-heading">
<span class="icon">👤</span>
<span class="user-name-row">
@@ -11,12 +11,15 @@
<div class="org-line" v-if="orgDisplayName">{{ orgDisplayName }}</div>
<div class="role-line" v-if="roleName">{{ roleName }}</div>
</div>
<span><strong>Visits:</strong></span>
<span>{{ visits || 0 }}</span>
<span><strong>Registered:</strong></span>
<span>{{ formatDate(createdAt) }}</span>
<span><strong>Last seen:</strong></span>
<span>{{ formatDate(lastSeen) }}</span>
<span class="info-label"><strong>Visits:</strong></span>
<span class="info-value">{{ visits || 0 }}</span>
<span class="info-label"><strong>Registered:</strong></span>
<span class="info-value">{{ formatDate(createdAt) }}</span>
<span class="info-label"><strong>Last seen:</strong></span>
<span class="info-value">{{ formatDate(lastSeen) }}</span>
<div v-if="$slots.default" class="user-info-extra">
<slot></slot>
</div>
</div>
</template>
@@ -44,13 +47,50 @@ const userLoaded = computed(() => !!props.name)
</script>
<style scoped>
.user-info { display: grid; grid-template-columns: auto 1fr; gap: 10px; }
.user-info h3 { grid-column: span 2; }
.org-role-sub { grid-column: span 2; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.user-info.has-extra {
grid-template-columns: auto 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3"
"extra extra";
}
.user-info:not(.has-extra) {
grid-template-columns: auto 1fr;
grid-template-areas:
"heading heading"
"org org"
"label1 value1"
"label2 value2"
"label3 value3";
}
@media (min-width: 769px) {
.user-info.has-extra {
grid-template-columns: auto 1fr 2fr;
grid-template-areas:
"heading heading extra"
"org org extra"
"label1 value1 extra"
"label2 value2 extra"
"label3 value3 extra";
}
}
.user-name-heading { grid-area: heading; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.org-role-sub { grid-area: org; display:flex; flex-direction:column; margin: -0.15rem 0 0.25rem; }
.org-line { font-size: .7rem; font-weight:600; line-height:1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.role-line { font-size:.65rem; color: var(--color-text-muted); line-height:1.1; }
.user-info span { text-align: left; }
.user-name-heading { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0 0 0.25rem 0; }
.info-label:nth-of-type(1) { grid-area: label1; }
.info-value:nth-of-type(2) { grid-area: value1; }
.info-label:nth-of-type(3) { grid-area: label2; }
.info-value:nth-of-type(4) { grid-area: value2; }
.info-label:nth-of-type(5) { grid-area: label3; }
.info-value:nth-of-type(6) { grid-area: value3; }
.user-info-extra { grid-area: extra; padding-left: 2rem; border-left: 1px solid var(--color-border); }
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; }
.user-name-row.editing { flex: 1 1 auto; }
.icon { flex: 0 0 auto; }
@@ -62,5 +102,6 @@ const userLoaded = computed(() => !!props.name)
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) { .user-info-extra { padding-left: 0; padding-top: 1rem; border-left: none; border-top: 1px solid var(--color-border); } }
@media (max-width: 480px) { .user-name-heading { flex-direction: column; align-items: flex-start; } .user-name-row.editing { width: 100%; } .display-name { max-width: 100%; } }
</style>
+28 -4
View File
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
}
this.onclose = e => {
if (!this.#opened) {
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
reject(new Error(`Failed to connect to server (code ${e.code})`))
return
}
this.#err = e.wasClean
? new Error(`Websocket ${this.url} closed ${e.code}`)
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
// Create user-friendly close messages
let message
if (e.wasClean) {
// Standard close codes
switch (e.code) {
case 1000: message = 'Connection closed normally'; break
case 1001: message = 'Server is going away'; break
case 1002: message = 'Protocol error'; break
case 1003: message = 'Unsupported data received'; break
case 1006: message = 'Connection lost unexpectedly'; break
case 1007: message = 'Invalid data received'; break
case 1008: message = 'Policy violation'; break
case 1009: message = 'Message too large'; break
case 1010: message = 'Extension negotiation failed'; break
case 1011: message = 'Server encountered an error'; break
case 1012: message = 'Server is restarting'; break
case 1013: message = 'Server is overloaded, try again later'; break
case 1014: message = 'Bad gateway'; break
case 1015: message = 'TLS handshake failed'; break
default: message = `Connection closed (code ${e.code})`
}
} else {
message = e.code === 1006
? 'Connection lost unexpectedly'
: `Connection closed with error (code ${e.code})`
}
this.#err = new Error(message)
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ async def get_reset(token: str) -> ResetToken:
record = await db.instance.get_reset_token(reset_key(token))
if record and record.expiry >= datetime.now(timezone.utc):
return record
raise ValueError("This reset link is invalid or has expired")
raise ValueError("This authentication link is no longer valid.")
async def get_session(token: str, host: str | None = None) -> Session:
+2 -1
View File
@@ -201,7 +201,8 @@ async def get_settings():
"rp_id": pk.rp_id,
"rp_name": pk.rp_name,
"ui_base_path": base_path,
"auth_host": hostutil.configured_auth_host(),
"auth_host": hostutil.dedicated_auth_host(),
"auth_site_url": hostutil.auth_site_url(),
"session_cookie": AUTH_COOKIE_NAME,
}
+1 -1
View File
@@ -73,7 +73,7 @@ def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Resp
async def redirect_middleware(request: Request, call_next):
"""Middleware to handle auth host redirects."""
cfg = hostutil.configured_auth_host()
cfg = hostutil.dedicated_auth_host()
if not cfg:
return await call_next(request)
+364 -210
View File
@@ -19,9 +19,9 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import remoteauth
from paskia.authsession import create_session
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import require_pow, validate_origin, websocket_error_handler
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import db, passkey
from paskia.util import hostutil, pow
from paskia.util import hostutil, passphrase, pow
# Create a FastAPI subapp for remote auth WebSocket endpoints
@@ -51,119 +51,196 @@ async def websocket_remote_auth_request(ws: WebSocket):
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Require HARD PoW before creating the request (SECURITY)
await require_pow(ws, work=pow.HARD)
metadata = infodict(ws, "remote-auth-request")
# Create the remote auth request
token, pairing_code, expiry = await remoteauth.instance.create_request(
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
)
# Build the URL for the authenticating device (same endpoint as reset tokens)
url = hostutil.auth_site_base_url() + token
# Send the token, pairing code, and URL to the client
await ws.send_json(
{
"token": token,
"pairing_code": pairing_code,
"url": url,
"expires": expiry.isoformat().replace("+00:00", "Z"),
}
)
# Set up async notification
result_event = asyncio.Event()
result_data: dict = {}
def on_complete(
session_token: str | None,
user_uuid: UUID | None,
credential_uuid: UUID | None,
):
result_data["session_token"] = session_token
result_data["user_uuid"] = user_uuid
result_data["credential_uuid"] = credential_uuid
result_event.set()
await remoteauth.instance.set_notify_callback(token, on_complete)
# 5 minute timeout for the entire remote auth flow
timeout_seconds = 5 * 60
# Track this WebSocket connection for load-based PoW difficulty
remoteauth.instance.increment_connections()
try:
# Wait for either:
# 1. Authentication to complete (result_event set)
# 2. Client to disconnect
# 3. Client to send a cancel message
# 4. Timeout after 5 minutes
# Send PoW challenge immediately with dynamic difficulty based on load
challenge = pow.generate_challenge()
work = remoteauth.instance.get_pow_difficulty()
async with asyncio.timeout(timeout_seconds):
while True:
# Use asyncio.wait to handle both event and websocket
receive_task = asyncio.create_task(ws.receive_json())
event_task = asyncio.create_task(result_event.wait())
await ws.send_json({
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
done, pending = await asyncio.wait(
[receive_task, event_task],
return_when=asyncio.FIRST_COMPLETED,
)
# Receive client response with PoW solution and action
response = await ws.receive_json()
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Verify PoW (required for this endpoint - SECURITY)
solution_b64 = response.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
if event_task in done:
# Authentication completed (or expired/cancelled)
if result_data.get("session_token"):
await ws.send_json(
{
"status": "authenticated",
"user_uuid": str(result_data["user_uuid"]),
"session_token": result_data["session_token"],
}
)
else:
await ws.send_json(
{
"status": "expired",
"detail": "Remote authentication request expired or was cancelled",
}
)
return
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
if receive_task in done:
# Client sent a message
msg = receive_task.result()
if msg.get("action") == "cancel":
await remoteauth.instance.cancel_request(token)
await ws.send_json({"status": "cancelled"})
return
# Ignore other messages
pow.verify_pow(challenge, solution, work)
except TimeoutError:
# 5 minute timeout reached
await remoteauth.instance.cancel_request(token)
# Extract action from the same message
action = response.get("action", "login")
if action not in ("login", "register"):
action = "login"
metadata = infodict(ws, "remote-auth-request")
# Create the remote auth request
token, pairing_code, expiry = await remoteauth.instance.create_request(
host=host,
ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "",
action=action,
)
# Send the token and pairing code to the client (URL built in frontend)
await ws.send_json(
{
"status": "timeout",
"detail": "Remote authentication request timed out after 5 minutes",
"token": token,
"pairing_code": pairing_code,
"expires": expiry.isoformat().replace("+00:00", "Z"),
}
)
except WebSocketDisconnect:
# Client disconnected, cancel the request
await remoteauth.instance.cancel_request(token)
except Exception:
await remoteauth.instance.cancel_request(token)
raise
# Set up async notification for completion
result_event = asyncio.Event()
result_data: dict = {}
def on_complete(
session_token: str | None,
user_uuid: UUID | None,
credential_uuid: UUID | None,
reset_token: str | None,
):
# Check if this was an explicit denial (UUID(int=0) is the signal)
was_denied = user_uuid is not None and user_uuid == UUID(int=0)
result_data["session_token"] = session_token
result_data["user_uuid"] = user_uuid
result_data["credential_uuid"] = credential_uuid
result_data["reset_token"] = reset_token
result_data["was_denied"] = was_denied
result_event.set()
await remoteauth.instance.set_notify_callback(token, on_complete)
# Set up async notification for action lock
locked_event = asyncio.Event()
locked_data: dict = {}
def on_action_locked(action: str):
locked_data["action"] = action
locked_event.set()
await remoteauth.instance.set_action_locked_callback(token, on_action_locked)
# 5 minute timeout for the entire remote auth flow
timeout_seconds = 5 * 60
try:
# Wait for either:
# 1. Authentication to complete (result_event set)
# 2. Action locked (locked_event set)
# 3. Client to disconnect
# 4. Client to send a cancel or update_action message
# 5. Timeout after 5 minutes
async with asyncio.timeout(timeout_seconds):
while True:
# Use asyncio.wait to handle events and websocket
receive_task = asyncio.create_task(ws.receive_json())
result_wait_task = asyncio.create_task(result_event.wait())
locked_wait_task = asyncio.create_task(locked_event.wait())
tasks = [receive_task, result_wait_task]
# Only wait for locked event if not already locked
if not locked_event.is_set():
tasks.append(locked_wait_task)
done, pending = await asyncio.wait(
tasks,
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if result_wait_task in done:
# Authentication completed (or expired/cancelled/denied)
was_denied = result_data.get("was_denied", False)
if result_data.get("session_token") or result_data.get("reset_token"):
response = {
"status": "authenticated",
"user_uuid": str(result_data["user_uuid"]),
}
if result_data.get("session_token"):
response["session_token"] = result_data["session_token"]
if result_data.get("reset_token"):
response["reset_token"] = result_data["reset_token"]
await ws.send_json(response)
else:
# Check if it was explicitly denied
if was_denied:
await ws.send_json(
{
"status": "denied",
"detail": "Access denied",
}
)
else:
await ws.send_json(
{
"status": "expired",
"detail": "Remote authentication request expired or was cancelled",
}
)
return
if locked_wait_task in done:
# Action was locked by the authenticating device
await ws.send_json({
"status": "locked",
"action": locked_data.get("action", "login"),
})
# Continue waiting for result
if receive_task in done:
# Client sent a message
msg = receive_task.result()
if msg.get("action") == "cancel":
await remoteauth.instance.cancel_request(token)
await ws.send_json({"status": "cancelled"})
return
elif msg.get("action") == "update_action":
# Update the action (login/register) if not locked
new_action = "register" if msg.get("register") else "login"
await remoteauth.instance.update_action(token, new_action)
# Ignore other messages
except TimeoutError:
# 5 minute timeout reached
await remoteauth.instance.cancel_request(token)
await ws.send_json(
{
"status": "timeout",
"detail": "Remote authentication request timed out after 5 minutes",
}
)
except WebSocketDisconnect:
# Client disconnected, cancel the request and mark as denied
await remoteauth.instance.cancel_request(token, denied=True)
except Exception:
await remoteauth.instance.cancel_request(token)
raise
finally:
# Decrement connection count
remoteauth.instance.decrement_connections()
@app.websocket("/pair")
@@ -197,7 +274,7 @@ async def websocket_remote_auth_pair(ws: WebSocket):
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Generate initial PoW challenge
# Generate initial PoW challenge (always NORMAL for authenticated users)
challenge = pow.generate_challenge()
work = pow.NORMAL
@@ -210,126 +287,203 @@ async def websocket_remote_auth_pair(ws: WebSocket):
request = None
webauthn_challenge = None
explicitly_denied = False
while True:
msg = await ws.receive_json()
try:
while True:
msg = await ws.receive_json()
# Check if this is a 5-word token (from link) - skip PoW validation
code = msg.get("code", "")
is_link_token = len(code.split(".")) == 5
if not is_link_token:
# Validate PoW for 3-word pairing codes
solution_b64 = msg.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
try:
pow.verify_pow(challenge, solution, work)
except ValueError as e:
# Invalid PoW - send new challenge
challenge = pow.generate_challenge()
# Handle deny request first (no PoW needed - already validated during lookup)
if msg.get("deny") and request is not None:
# Cancel the request and mark it as denied
explicitly_denied = True
await remoteauth.instance.cancel_request(request.key, denied=True)
await ws.send_json({
"status": 400,
"detail": str(e),
"status": "denied",
"message": "Request denied",
})
break
# Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None:
# Generate authentication options
options, webauthn_challenge = passkey.instance.auth_generate_options(
credential_ids=None
)
await ws.send_json({"optionsJSON": options})
# Wait for WebAuthn response
credential = passkey.instance.auth_parse(await ws.receive_json())
# Fetch and verify credential
try:
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
except ValueError:
raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}"
)
# Verify the credential
passkey.instance.auth_verify(
credential, webauthn_challenge, stored_cred, origin
)
# Update credential last_used
await db.instance.login(stored_cred.user_uuid, stored_cred)
# Create a session for the REQUESTING device
assert stored_cred.uuid is not None
session_token = None
reset_token = None
if request.action == "register":
# For registration, create a reset token for device addition
from paskia.authsession import expires
from paskia.util import tokens
token_str = passphrase.generate()
expiry = expires()
await db.instance.create_reset_token(
user_uuid=stored_cred.user_uuid,
key=tokens.reset_key(token_str),
expiry=expiry,
token_type="device addition",
)
reset_token = token_str
# Also create a session so the device is logged in?
# User requested: "We can make the flow always create a new session, but make additional tokens for other possibilities."
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
ip=request.ip,
user_agent=request.user_agent,
)
else:
# Default login action
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
ip=request.ip,
user_agent=request.user_agent,
)
# Complete the remote auth request (notifies the waiting device)
completed = await remoteauth.instance.complete_request(
token=request.key,
session_token=session_token,
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
reset_token=reset_token,
)
if not completed:
raise ValueError("Failed to complete remote authentication")
msg = "Authentication successful."
if request.action == "register":
msg += " The other device can now register a passkey."
else:
msg += " The other device is now logged in."
await ws.send_json({
"status": "success",
"message": msg,
})
break
# Handle code lookup request - requires PoW validation
code = msg.get("code", "")
is_link_token = len(code.split(".")) == 5
if not is_link_token:
# Validate PoW for 3-word pairing codes
solution_b64 = msg.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
try:
solution = base64url.dec(solution_b64)
except Exception:
raise ValueError("Invalid PoW solution encoding")
try:
pow.verify_pow(challenge, solution, work)
except ValueError as e:
# Invalid PoW - send new challenge
challenge = pow.generate_challenge()
await ws.send_json({
"status": 400,
"detail": str(e),
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
continue
if not code:
raise ValueError("Pairing code required")
# Look up the remote auth request by pairing code or token
if is_link_token:
request = await remoteauth.instance.get_request(code)
else:
request = await remoteauth.instance.get_request_by_pairing_code(code)
# Generate new challenge for next request (always NORMAL for authenticated users)
challenge = pow.generate_challenge()
if request is None:
await ws.send_json({
"status": 404,
"detail": "Code not found",
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
request = None # Reset for next attempt
continue
# Handle authenticate request (after successful lookup)
if msg.get("authenticate") and request is not None:
# Generate authentication options
options, webauthn_challenge = passkey.instance.auth_generate_options(
credential_ids=None
)
await ws.send_json({"optionsJSON": options})
# Valid code found - lock the action so it can't be changed anymore
# This also notifies the requesting device
locked_action = await remoteauth.instance.lock_action(request.key)
if locked_action is None:
# Already locked by another device
await ws.send_json({
"status": 409,
"detail": "This request is already being processed in another window",
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
request = None # Reset for next attempt
continue
# Wait for WebAuthn response
credential = passkey.instance.auth_parse(await ws.receive_json())
# Fetch and verify credential
try:
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
except ValueError:
raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}"
)
# Verify the credential
passkey.instance.auth_verify(
credential, webauthn_challenge, stored_cred, origin
)
# Update credential last_used
await db.instance.login(stored_cred.user_uuid, stored_cred)
# Create a session for the REQUESTING device
assert stored_cred.uuid is not None
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=request.host,
ip=request.ip,
user_agent=request.user_agent,
)
# Complete the remote auth request (notifies the waiting device)
completed = await remoteauth.instance.complete_request(
token=request.key,
session_token=session_token,
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
)
if not completed:
raise ValueError("Failed to complete remote authentication")
request.action = locked_action # Update local copy with locked value
# Send device info to the authenticating device
await ws.send_json({
"status": "success",
"message": "Authentication successful. The other device is now logged in.",
})
break
# Handle code lookup request
if not code:
raise ValueError("Pairing code required")
# Look up the remote auth request by pairing code or token
if is_link_token:
request = await remoteauth.instance.get_request(code)
else:
request = await remoteauth.instance.get_request_by_pairing_code(code)
# Generate new challenge for next request
challenge = pow.generate_challenge()
if request is None:
await ws.send_json({
"status": 404,
"detail": "Code not found",
"status": "found",
"host": request.host,
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"client_ip": request.ip,
"action": request.action,
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
request = None # Reset for next attempt
continue
# Valid code found - send device info
await ws.send_json({
"status": "found",
"host": request.host,
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
except Exception:
# If websocket disconnects without explicit denial, unlock the request
if request and not explicitly_denied:
# Unlock the request so the code can be used again
async with remoteauth.instance._lock:
req = remoteauth.instance._requests.get(request.key)
if req and req.locked:
req.locked = False
raise
+120 -11
View File
@@ -43,14 +43,21 @@ class RemoteAuthRequest:
host: str # The host where the session should be created
ip: str # IP of the requesting device
user_agent: str # User agent of the requesting device
action: str = "login" # "login" or "register"
locked: bool = False # True once the authenticating device has entered the code
# Callback to notify the requesting device when auth completes
# Takes (session_token, user_uuid, credential_uuid) or (None, None, None) on cancel/expire
notify: Callable[[str | None, UUID | None, UUID | None], None] | None = None
# Takes (session_token, user_uuid, credential_uuid, reset_token) or (None, None, None, None) on cancel/expire
notify: Callable[[str | None, UUID | None, UUID | None, str | None], None] | None = None
# Callback to notify the requesting device when action is locked
# Takes (action) to confirm what action was locked
action_locked_notify: Callable[[str], None] | None = None
# Set when authentication completes
completed: bool = False
denied: bool = False # True if explicitly denied by the authenticating device
session_token: str | None = None
user_uuid: UUID | None = None
credential_uuid: UUID | None = None
reset_token: str | None = None
def _generate_pairing_code() -> str:
@@ -110,7 +117,7 @@ class RemoteAuthManager:
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify and not req.completed:
try:
req.notify(None, None, None)
req.notify(None, None, None, None)
except Exception:
pass
@@ -119,6 +126,7 @@ class RemoteAuthManager:
host: str,
ip: str,
user_agent: str,
action: str = "login",
) -> tuple[str, str, datetime]:
"""Create a new remote auth request.
@@ -151,6 +159,7 @@ class RemoteAuthManager:
host=host,
ip=ip,
user_agent=user_agent,
action=action,
)
self._requests[token] = request
@@ -200,7 +209,7 @@ class RemoteAuthManager:
async def set_notify_callback(
self,
token: str,
callback: Callable[[str | None, UUID | None, UUID | None], None],
callback: Callable[[str | None, UUID | None, UUID | None, str | None], None],
) -> bool:
"""Set the notification callback for a request.
@@ -213,12 +222,72 @@ class RemoteAuthManager:
req.notify = callback
return True
async def set_action_locked_callback(
self,
token: str,
callback: Callable[[str], None],
) -> bool:
"""Set the callback for when the action is locked.
Returns True if the request exists and callback was set.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return False
req.action_locked_notify = callback
return True
async def update_action(
self,
token: str,
action: str,
) -> bool:
"""Update the action for a request (only if not locked).
Returns True if the request exists and was updated.
"""
if action not in ("login", "register"):
return False
async with self._lock:
req = self._requests.get(token)
if req is None or req.locked:
return False
req.action = action
return True
async def lock_action(
self,
token: str,
) -> str | None:
"""Lock the action for a request (called when authenticating device enters code).
Returns the locked action, or None if request doesn't exist or is already locked.
Notifies the requesting device via action_locked_notify callback.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return None
if req.locked:
# Already locked by another authenticating device
return None
req.locked = True
action = req.action
if req.action_locked_notify:
try:
req.action_locked_notify(action)
except Exception:
pass
return action
async def complete_request(
self,
token: str,
session_token: str,
session_token: str | None,
user_uuid: UUID,
credential_uuid: UUID,
reset_token: str | None = None,
) -> bool:
"""Mark a request as completed with the authentication result.
@@ -233,27 +302,67 @@ class RemoteAuthManager:
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify:
try:
req.notify(session_token, user_uuid, credential_uuid)
req.notify(session_token, user_uuid, credential_uuid, reset_token)
except Exception:
pass
return True
async def cancel_request(self, token: str) -> bool:
async def cancel_request(self, token: str, *, denied: bool = False) -> RemoteAuthRequest | None:
"""Cancel and remove a request.
Returns True if the request existed and was removed.
Args:
token: The request token
denied: If True, marks this as an explicit denial (not just timeout/disconnect)
Returns the removed request if it existed, None otherwise.
"""
async with self._lock:
req = self._requests.pop(token, None)
if req is None:
return False
return None
self._by_pairing_code.pop(req.pairing_code, None)
if denied:
req.denied = True
if req.notify and not req.completed:
try:
req.notify(None, None, None)
# Pass denied status through a special UUID value (all zeros means denied)
if denied:
req.notify(None, UUID(int=0), None, None)
else:
req.notify(None, None, None, None)
except Exception:
pass
return True
return req
def get_connection_count(self) -> int:
"""Get the current count of open WebSocket connections.
This is used to determine PoW difficulty based on load.
"""
# Count is maintained externally by the WebSocket endpoints
return getattr(self, '_ws_count', 0)
def increment_connections(self) -> None:
"""Increment the WebSocket connection counter."""
self._ws_count = getattr(self, '_ws_count', 0) + 1
def decrement_connections(self) -> None:
"""Decrement the WebSocket connection counter."""
self._ws_count = max(0, getattr(self, '_ws_count', 0) - 1)
def get_pow_difficulty(self) -> int:
"""Get PoW difficulty based on current WebSocket connection count.
Uses NORMAL difficulty with low load (< 10 connections),
HARD difficulty with high load (>= 10 connections).
Returns:
PoW work units (pow.NORMAL or pow.HARD)
"""
from paskia.util import pow
count = self.get_connection_count()
return pow.HARD if count >= 10 else pow.NORMAL
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
"""Get and remove a request (for use by the authenticating device)."""
+3 -3
View File
@@ -19,7 +19,7 @@ def is_root_mode() -> bool:
return _load_config().get("auth_host") is not None
def configured_auth_host() -> str | None:
def dedicated_auth_host() -> str | None:
"""Return configured auth_host netloc, or None."""
auth_host = _load_config().get("auth_host")
if not auth_host:
@@ -34,7 +34,7 @@ def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def auth_site_base_url() -> str:
def auth_site_url() -> str:
"""Return the base URL for the auth site UI (computed at startup)."""
cfg = _load_config()
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/")
@@ -42,7 +42,7 @@ def auth_site_base_url() -> str:
def reset_link_url(token: str) -> str:
"""Generate a reset link URL for the given token."""
return f"{auth_site_base_url()}{token}"
return f"{auth_site_url()}{token}"
def normalize_origin(origin: str) -> str: