Drafting remote auth linking.

This commit is contained in:
Leo Vasanko
2025-12-06 16:43:47 +00:00
parent 2b7481cd58
commit 4da1127976
9 changed files with 1554 additions and 4 deletions
+35 -1
View File
@@ -1,5 +1,14 @@
<template>
<!-- Remote auth completion mode -->
<RemoteAuthComplete
v-if="remoteAuthToken"
:token="remoteAuthToken"
@back="handleBack"
@completed="handleRemoteCompleted"
/>
<!-- Normal restricted auth mode -->
<RestrictedAuth
v-else
:mode="authMode"
@authenticated="handleAuthenticated"
@back="handleBack"
@@ -7,8 +16,25 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
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: /remote/{token} or /auth/remote/{token}
const remoteAuthToken = ref(null)
function extractRemoteToken() {
const path = window.location.pathname
const match = path.match(/\/(?:auth\/)?remote\/([^/]+)$/)
if (match) {
const token = match[1]
// Validate it looks like a passphrase (contains dots)
if (token.includes('.')) {
return token
}
}
return null
}
// Detect mode from URL hash fragment
const authMode = computed(() => {
@@ -39,7 +65,15 @@ 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()
postToParent({
type: 'auth-ready'
})
@@ -0,0 +1,216 @@
<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-group">
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
:disabled="loading"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class="pairing-input"
@input="handleInput"
/>
<button
type="submit"
:disabled="!isValid || loading"
class="btn-primary"
>
{{ loading ? 'Connecting…' : 'Connect' }}
</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, onMounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
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 code' }
})
const emit = defineEmits(['completed', 'error', 'cancelled'])
const inputRef = ref(null)
const code = ref('')
const loading = ref(false)
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
let ws = null
// Valid if we have 3 words separated by dots or spaces
const isValid = computed(() => {
const trimmed = code.value.trim()
if (!trimmed) return false
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
return words.length >= 3
})
function handleInput() {
error.value = null
}
async function submitCode() {
if (!isValid.value || loading.value) return
loading.value = true
error.value = null
try {
const settings = await getSettings()
const authHost = settings?.auth_host
// Normalize the code: lowercase words joined by dots
const normalizedCode = code.value.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// Receive authentication options
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Connection failed: ${res.status}`)
}
// Perform WebAuthn authentication
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.'
emit('completed')
} 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 || 'Failed to connect')
error.value = message
emit('error', message)
} finally {
loading.value = false
if (ws) {
ws.close()
ws = null
}
}
}
function reset() {
code.value = ''
error.value = null
completed.value = false
completedMessage.value = ''
}
onMounted(() => {
inputRef.value?.focus()
})
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-group {
display: flex;
gap: 0.5rem;
}
.pairing-input {
flex: 1;
padding: 0.625rem 0.75rem;
font-size: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
background: var(--color-surface);
color: var(--color-text);
}
.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;
}
.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>
+32
View File
@@ -52,6 +52,23 @@
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">
@@ -102,6 +119,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 { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
@@ -116,6 +134,7 @@ const newName = ref('')
const saving = ref(false)
const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null)
const pairingEntry = ref(null)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
@@ -138,6 +157,19 @@ const addNewCredential = async () => {
}
}
const handlePairingCompleted = () => {
authStore.showMessage('The other device is now signed in!', 'success', 4000)
// Reset the form after a delay
setTimeout(() => pairingEntry.value?.reset(), 3000)
}
const handlePairingError = (message) => {
// Error is already shown in the component, optionally show global message for severe errors
if (!message.includes('cancelled')) {
authStore.showMessage(message, 'error', 4000)
}
}
const handleDelete = async (credential) => {
const credentialId = credential?.credential_uuid
if (!credentialId) return
@@ -0,0 +1,224 @@
<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 { getSettings, uiBasePath } from '@/utils/settings'
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)
// 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>
@@ -0,0 +1,417 @@
<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 { getSettings } from '@/utils/settings'
import { formatDate } from '@/utils/helpers'
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)
// 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>
+40 -1
View File
@@ -23,12 +23,16 @@
:is-authenticated="isAuthenticated"
:authenticate="authenticateUser"
:logout="logoutUser"
:mode="mode">
: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') }}
</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>
@@ -37,6 +41,15 @@
</section>
</div>
</main>
<!-- Remote Auth Modal -->
<RemoteAuthLinkModal
:active="showRemoteAuth"
@authenticated="handleRemoteAuthenticated"
@cancelled="showRemoteAuth = false"
@close="showRemoteAuth = false"
@error="handleRemoteAuthError"
/>
</div>
</template>
@@ -45,6 +58,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthLinkModal from '@/components/RemoteAuthLinkModal.vue'
const props = defineProps({
mode: {
@@ -62,6 +76,7 @@ const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const showRemoteAuth = ref(false)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -196,6 +211,30 @@ async function setSessionCookie(result) {
})
}
// Remote authentication from another device
function startRemoteAuth() {
showRemoteAuth.value = true
}
async function handleRemoteAuthenticated(result) {
showRemoteAuth.value = false
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
emit('auth-error', { message, cancelled: false })
return
}
emit('authenticated', result)
}
function handleRemoteAuthError(errorMsg) {
showRemoteAuth.value = false
showMessage(errorMsg || 'Remote authentication failed', 'error', 4000)
}
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
+15 -1
View File
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from paskia import remoteauth
from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import frontend, hostutil, passphrase
@@ -37,13 +38,17 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
origins=config["origins"],
bootstrap=False,
)
# Initialize remote authentication manager
await remoteauth.init()
except ValueError as e:
logging.error(f"⚠️ {e}")
# Re-raise to fail fast
raise
yield
# (Optional) add shutdown cleanup here later
# Shutdown cleanup
await remoteauth.shutdown()
app = FastAPI(lifespan=lifespan)
@@ -113,6 +118,15 @@ async def examples_page():
# Note: this catch-all handler must be the last route defined
@app.get("/remote/{token}")
@app.get("/auth/remote/{token}")
async def remote_auth_link(token: str):
"""Serve the restricted app for cross-device login."""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404)
return Response(*await frontend.read("/auth/restricted/index.html"))
@app.get("/{reset}")
@app.get("/auth/{reset}")
async def reset_link(reset: str):
+291 -1
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
from functools import wraps
from uuid import UUID
@@ -5,11 +6,12 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from paskia import remoteauth
from paskia.authsession import create_session, get_reset, get_session
from paskia.fastapi import authz
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.globals import db, passkey
from paskia.util import passphrase
from paskia.util import hostutil, passphrase
from paskia.util.tokens import create_token, session_key
@@ -198,3 +200,291 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
"session_token": token,
}
)
@app.websocket("/remote-auth/request")
@websocket_error_handler
async def websocket_remote_auth_request(ws: WebSocket):
"""Request authentication from another device.
This endpoint is called by the device that wants to be authenticated.
It creates a remote auth request and waits for another device to authenticate.
Flow:
1. Client connects
2. Server creates a remote auth token and sends it with URL/expiry/pairing_code
3. Server waits for another device to authenticate via /remote-auth/complete
4. When auth completes, server sends session_token to this client
5. Client can then use the session token to set a cookie
"""
origin = _validate_origin(ws)
host = origin.split("://", 1)[1]
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
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
url = hostutil.auth_site_base_url() + f"remote/{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)
try:
# Wait for either:
# 1. Authentication to complete (result_event set)
# 2. Client to disconnect
# 3. Client to send a cancel message
# 4. Timeout (handled by remoteauth cleanup)
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())
done, pending = await asyncio.wait(
[receive_task, event_task],
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
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",
}
)
break
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"})
break
# Ignore other messages
except WebSocketDisconnect:
# Client disconnected, cancel the request
await remoteauth.instance.cancel_request(token)
except Exception:
await remoteauth.instance.cancel_request(token)
raise
@app.websocket("/remote-auth/complete/{token}")
@websocket_error_handler
async def websocket_remote_auth_complete(ws: WebSocket, token: str):
"""Complete a remote authentication request.
This endpoint is called by the authenticating device (the one with the passkey).
It performs WebAuthn authentication and notifies the requesting device.
Flow:
1. Client opens the remote auth link and connects here
2. Server verifies the token is valid
3. Server sends WebAuthn options
4. Client authenticates with passkey
5. Server creates session for the REQUESTING device's host
6. Server notifies the requesting device via the callback
7. Server sends confirmation to this client
"""
origin = _validate_origin(ws)
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Validate the remote auth token
request = await remoteauth.instance.get_request(token)
if request is None:
raise ValueError("This remote authentication link is invalid or has expired")
if request.completed:
raise ValueError("This remote authentication has already been completed")
# The session will be created for the requesting device's host, not this device's
target_host = request.host
# Generate authentication options (no credential restriction for remote auth)
options, challenge = passkey.instance.auth_generate_options(credential_ids=None)
await ws.send_json({"optionsJSON": options})
# Wait for client authentication 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, 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=target_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=token,
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")
# Send confirmation to the authenticating device
await ws.send_json(
{
"status": "success",
"message": "Authentication successful. The other device is now logged in.",
}
)
@app.websocket("/remote-auth/pair/{code}")
@websocket_error_handler
async def websocket_remote_auth_pair(ws: WebSocket, code: str):
"""Complete a remote authentication request using a pairing code.
This endpoint is called from the user's profile on the authenticating device.
The user enters the pairing code displayed on the requesting device.
Flow:
1. User on Device B (with passkey) enters pairing code from Device A
2. Server looks up the remote auth request by pairing code
3. Server sends WebAuthn options
4. User authenticates with passkey
5. Server creates session for Device A's host with Device A's metadata
6. Server notifies Device A via the callback
7. Server sends confirmation to Device B
"""
origin = _validate_origin(ws)
if remoteauth.instance is None:
raise ValueError("Remote authentication is not available")
# Look up the remote auth request by pairing code
request = await remoteauth.instance.get_request_by_pairing_code(code)
if request is None:
raise ValueError("Invalid or expired pairing code")
if request.completed:
raise ValueError("This remote authentication has already been completed")
# The session will be created for the requesting device's host
target_host = request.host
# Generate authentication options (no credential restriction for remote auth)
options, challenge = passkey.instance.auth_generate_options(credential_ids=None)
await ws.send_json({"optionsJSON": options})
# Wait for client authentication 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, 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 (with their IP/user-agent)
assert stored_cred.uuid is not None
session_token = await create_session(
user_uuid=stored_cred.user_uuid,
credential_uuid=stored_cred.uuid,
host=target_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")
# Send confirmation to the authenticating device
await ws.send_json(
{
"status": "success",
"message": "Authentication successful. The other device is now logged in.",
}
)
+284
View File
@@ -0,0 +1,284 @@
"""
Cross-device (remote) authentication support.
This module manages the flow for authenticating from another device:
1. Device A (requesting) creates a remote auth request and displays QR/link
2. Device B (authenticating) opens the link and authenticates with passkey
3. Device A receives the session via WebSocket notification
Alternative flow (initiated from profile/authenticating device):
1. Device A (requesting) creates request and displays short pairing code
2. Device B (authenticating) enters the pairing code in their profile
3. Device B authenticates, Device A receives the session
The requests are stored in-memory with short expiration (5 minutes).
"""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable
from uuid import UUID
from paskia.util import passphrase
# Remote auth requests expire after this duration
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
# Number of words for the short pairing code (easier to communicate than alphanumeric)
PAIRING_CODE_WORDS = 3
@dataclass
class RemoteAuthRequest:
"""A pending remote authentication request."""
key: str # The passphrase token
pairing_code: str # Short alphanumeric code for manual entry
created_at: datetime
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
# 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
# Set when authentication completes
completed: bool = False
session_token: str | None = None
user_uuid: UUID | None = None
credential_uuid: UUID | None = None
def _generate_pairing_code() -> str:
"""Generate a short, easy-to-communicate pairing code using words."""
return passphrase.generate(n=PAIRING_CODE_WORDS)
class RemoteAuthManager:
"""Manages pending remote authentication requests."""
def __init__(self):
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token
self._by_pairing_code: dict[str, str] = {} # pairing_code -> token
self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def start(self):
"""Start the cleanup background task."""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def stop(self):
"""Stop the cleanup background task."""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _cleanup_loop(self):
"""Periodically clean up expired requests."""
while True:
try:
await asyncio.sleep(60) # Check every minute
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
logging.exception("Error in remote auth cleanup loop")
async def _cleanup_expired(self):
"""Remove expired requests and notify waiting clients."""
now = datetime.now(timezone.utc)
expired_keys = []
async with self._lock:
for key, req in self._requests.items():
if now > req.created_at + REMOTE_AUTH_LIFETIME:
expired_keys.append(key)
for key in expired_keys:
req = self._requests.pop(key)
# Also remove from pairing code index
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify and not req.completed:
try:
req.notify(None, None, None)
except Exception:
pass
async def create_request(
self,
host: str,
ip: str,
user_agent: str,
) -> tuple[str, str, datetime]:
"""Create a new remote auth request.
Returns:
(token, pairing_code, expiry) - The passphrase token, short pairing code, and expiration time
"""
token = passphrase.generate()
pairing_code = _generate_pairing_code()
now = datetime.now(timezone.utc)
expiry = now + REMOTE_AUTH_LIFETIME
request = RemoteAuthRequest(
key=token,
pairing_code=pairing_code,
created_at=now,
host=host,
ip=ip,
user_agent=user_agent,
)
async with self._lock:
# Ensure pairing code is unique (regenerate if collision)
while pairing_code in self._by_pairing_code:
pairing_code = _generate_pairing_code()
request.pairing_code = pairing_code
self._requests[token] = request
self._by_pairing_code[pairing_code] = token
return token, pairing_code, expiry
async def get_request(self, token: str) -> RemoteAuthRequest | None:
"""Get a pending request by token, if valid and not expired."""
if not passphrase.is_well_formed(token):
return None
async with self._lock:
req = self._requests.get(token)
if req is None:
return None
now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME:
# Expired
del self._requests[token]
self._by_pairing_code.pop(req.pairing_code, None)
return None
return req
async def get_request_by_pairing_code(self, code: str) -> RemoteAuthRequest | None:
"""Get a pending request by pairing code, if valid and not expired."""
# Normalize: lowercase, dot-separated words
normalized = code.lower().strip().replace(" ", ".")
# Validate it's a well-formed short passphrase
if not passphrase.is_well_formed(normalized, n=PAIRING_CODE_WORDS):
return None
async with self._lock:
token = self._by_pairing_code.get(normalized)
if token is None:
return None
req = self._requests.get(token)
if req is None:
self._by_pairing_code.pop(normalized, None)
return None
now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME:
# Expired
del self._requests[token]
self._by_pairing_code.pop(normalized, None)
return None
return req
async def set_notify_callback(
self,
token: str,
callback: Callable[[str | None, UUID | None, UUID | None], None],
) -> bool:
"""Set the notification callback for a request.
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.notify = callback
# If already completed, notify immediately
if req.completed:
try:
callback(req.session_token, req.user_uuid, req.credential_uuid)
except Exception:
pass
return True
async def complete_request(
self,
token: str,
session_token: str,
user_uuid: UUID,
credential_uuid: UUID,
) -> bool:
"""Mark a request as completed with the authentication result.
Returns True if the request existed and was completed.
"""
async with self._lock:
req = self._requests.get(token)
if req is None:
return False
if req.completed:
return False # Already completed
req.completed = True
req.session_token = session_token
req.user_uuid = user_uuid
req.credential_uuid = credential_uuid
if req.notify:
try:
req.notify(session_token, user_uuid, credential_uuid)
except Exception:
pass
return True
async def cancel_request(self, token: str) -> bool:
"""Cancel and remove a request.
Returns True if the request existed and was removed.
"""
async with self._lock:
req = self._requests.pop(token, None)
if req is None:
return False
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify and not req.completed:
try:
req.notify(None, None, None)
except Exception:
pass
return True
async def consume_request(self, token: str) -> RemoteAuthRequest | None:
"""Get and remove a request (for use by the authenticating device)."""
if not passphrase.is_well_formed(token):
return None
async with self._lock:
req = self._requests.get(token)
if req is None:
return None
now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME:
del self._requests[token]
return None
# Don't remove yet - wait until completion
return req
# Global instance
instance: RemoteAuthManager | None = None
async def init():
"""Initialize the global remote auth manager."""
global instance
instance = RemoteAuthManager()
await instance.start()
async def shutdown():
"""Shutdown the global remote auth manager."""
global instance
if instance:
await instance.stop()
instance = None