Cleanup, restore reset link functionality as it were, ruff and removed leftover remoteauth functionality.

This commit is contained in:
Leo Vasanko
2025-12-08 23:51:51 +00:00
parent 99c60f0e16
commit f3118b7c1f
13 changed files with 181 additions and 543 deletions
+6 -81
View File
@@ -1,8 +1,8 @@
<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">
<!-- Code input (shown when device info not yet received) -->
<div v-if="!deviceInfo" 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">
@@ -95,8 +95,7 @@ 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)
action: { type: String, default: 'login' } // 'login' or 'register'
})
const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
@@ -591,7 +590,7 @@ async function submitCode() {
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)
const authResponse = await startAuthentication(res)
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')
@@ -616,70 +615,6 @@ async function submitCode() {
}
}
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) {
@@ -694,19 +629,9 @@ async function deny() {
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 to initial state
reset()
}
} else {
// In pairing mode, just reset to initial state
reset()
}
}
function reset() {
code.value = ''
@@ -741,7 +666,7 @@ onUnmounted(() => {
if (ws) { ws.close(); ws = null }
})
defineExpose({ reset, deny, code, handleInput, authenticateWithToken, loading, error })
defineExpose({ reset, deny, code, handleInput, loading, error })
</script>
<style scoped>
+7 -164
View File
@@ -23,19 +23,15 @@
</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>
<span>Generating code</span>
</div>
</div>
<!-- Waiting/Authenticating phase - show codes and QR -->
<!-- Waiting/Authenticating phase - show codes -->
<div v-else class="auth-display">
<div class="auth-content">
<div v-if="pairingCode" class="pairing-code-section">
@@ -47,16 +43,6 @@
</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">
@@ -68,8 +54,7 @@
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode/lib/browser'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
@@ -82,18 +67,13 @@ const props = defineProps({
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, ' ') : '')
@@ -167,9 +147,7 @@ function stopWordAnimation() {
async function startRemoteAuth() {
error.value = null
completed.value = false
url.value = null
pairingCode.value = null
expires.value = null
phase.value = 'connecting'
// Start word animation
@@ -191,27 +169,20 @@ async function startRemoteAuth() {
ws.send_json({ pow: b64enc(nonces), action: 'login' })
}
// Receive the remote auth token and pairing code
// Receive the 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()
@@ -254,40 +225,6 @@ async function startRemoteAuth() {
}
}
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()
}
@@ -301,7 +238,7 @@ function cancel() {
}
watch(() => props.active, (newVal) => {
if (newVal && !url.value && !error.value && !completed.value) {
if (newVal && !pairingCode.value && !error.value && !completed.value) {
startRemoteAuth()
}
})
@@ -317,9 +254,6 @@ onUnmounted(() => {
ws.close()
ws = null
}
if (copyToastTimer) {
clearTimeout(copyToastTimer)
}
stopWordAnimation()
})
@@ -519,66 +453,7 @@ defineExpose({ retry, cancel })
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 {
.waiting-indicator {
display: flex;
align-items: center;
justify-content: center;
@@ -632,33 +507,6 @@ defineExpose({ retry, cancel })
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 {
@@ -667,15 +515,10 @@ defineExpose({ retry, cancel })
align-items: center;
}
.pairing-code-section,
.qr-section {
.pairing-code-section {
width: 100%;
max-width: 280px;
}
.qr-section {
max-width: 180px;
}
}
@media (max-width: 480px) {
+1 -46
View File
@@ -47,28 +47,6 @@
@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>
</div>
</div>
</div>
</section>
</div>
@@ -82,17 +60,12 @@ import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
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
}
})
@@ -104,8 +77,7 @@ const loading = ref(false)
const settings = ref(null)
const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local', 'remote', or 'complete'
const remoteAuthRef = ref(null)
const authView = ref('local') // 'local' or 'remote'
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -118,9 +90,6 @@ const canAuthenticate = computed(() => {
})
const headingTitle = computed(() => {
if (authView.value === 'complete') {
return `🔐 ${settings.value?.rp_name || location.origin}`
}
if (props.mode === 'reauth') {
return `🔐 Additional Authentication`
}
@@ -129,9 +98,6 @@ 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.'
}
@@ -276,12 +242,6 @@ function handleRemoteAuthError(errorMsg) {
// 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')) {
@@ -300,11 +260,6 @@ onMounted(async () => {
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)
})
+5 -43
View File
@@ -13,7 +13,6 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import remoteauth
from paskia.authsession import (
EXPIRES,
get_reset,
@@ -25,7 +24,7 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.globals import db
from paskia.globals import passkey as global_passkey
from paskia.util import frontend, hostutil, htmlutil, passphrase, useragent, userinfo
from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
from paskia.util.tokens import session_key
bearer_auth = HTTPBearer(auto_error=True)
@@ -209,31 +208,16 @@ async def get_settings():
@app.get("/token-info")
async def api_token_info(token: str):
"""Get information about a token (remote auth or reset token).
This endpoint allows the frontend to determine what type of token it is
dealing with and get relevant information for display.
"""Get information about a reset token.
Returns:
- type: "remote_auth" or "reset"
- For remote_auth: host, user_agent_pretty (requesting device info)
- For reset: user info (display name, etc.)
- type: "reset"
- user_name: display name of the user
- token_type: type of reset token
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404, detail="Invalid token")
# Check if this is a remote auth token
if remoteauth.instance is not None:
request = await remoteauth.instance.get_request(token)
if request is not None:
return {
"type": "remote_auth",
"host": request.host,
"user_agent": request.user_agent,
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"ip": request.ip,
}
# Check if this is a reset token
try:
reset_token = await get_reset(token)
@@ -247,28 +231,6 @@ async def api_token_info(token: str):
raise HTTPException(status_code=404, detail="Token not found or expired")
@app.get("/remote-auth-info")
async def api_remote_auth_info(code: str):
"""Get information about a remote auth request by pairing code (first 3 words).
This is used for real-time lookup as the user types the pairing code.
Returns info about the requesting device without initiating authentication.
"""
if remoteauth.instance is None:
raise HTTPException(status_code=404, detail="Remote auth not available")
request = await remoteauth.instance.get_request_by_pairing_code(code)
if request is None:
raise HTTPException(status_code=404, detail="Invalid or expired code")
return {
"host": request.host,
"user_agent": request.user_agent,
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"ip": request.ip,
}
@app.post("/user-info")
async def api_user_info(
request: Request,
+2 -19
View File
@@ -7,7 +7,6 @@ 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
@@ -38,8 +37,6 @@ 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
@@ -52,9 +49,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
yield
# Shutdown cleanup
await remoteauth.shutdown()
app = FastAPI(lifespan=lifespan)
@@ -126,22 +120,11 @@ async def examples_page():
@app.get("/{token}")
@app.get("/auth/{token}")
async def token_link(token: str):
"""Serve the appropriate app based on token type.
"""Serve the reset app for reset tokens (password reset / device addition).
This endpoint handles both:
- Remote auth tokens (cross-device login): serve restricted app
- Reset tokens (password reset / device addition): serve reset app
The frontend will detect the type by calling /auth/api/token-info.
The frontend will validate the token via /auth/api/token-info.
"""
if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404)
# Check if this is a remote auth token first (they're in-memory, fast lookup)
if remoteauth.instance is not None:
request = await remoteauth.instance.get_request(token)
if request is not None:
return Response(*await frontend.read("/auth/restricted/index.html"))
# Otherwise, serve the reset app (it will validate the token via API)
return Response(*await frontend.read("/int/reset/index.html"))
+65 -50
View File
@@ -13,7 +13,6 @@ import asyncio
from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import remoteauth
@@ -21,8 +20,7 @@ from paskia.authsession import create_session
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import db, passkey
from paskia.util import hostutil, passphrase, pow
from paskia.util import passphrase, pow
# Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI()
@@ -39,7 +37,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
Flow:
1. Client connects
2. Server sends HARD PoW challenge, client solves and responds
3. Server creates a remote auth token and sends it with URL/expiry/pairing_code
3. Server creates a 3-word pairing code and sends it with expiry
4. Server waits for another device to authenticate via /remote-auth/pair
5. When auth completes, server sends session_token to this client
6. Client can then use the session token to set a cookie
@@ -58,12 +56,14 @@ async def websocket_remote_auth_request(ws: WebSocket):
challenge = pow.generate_challenge()
work = remoteauth.instance.get_pow_difficulty()
await ws.send_json({
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
}
)
# Receive client response with PoW solution and action
response = await ws.receive_json()
@@ -88,17 +88,16 @@ async def websocket_remote_auth_request(ws: WebSocket):
metadata = infodict(ws, "remote-auth-request")
# Create the remote auth request
token, pairing_code, expiry = await remoteauth.instance.create_request(
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)
# Send the pairing code to the client
await ws.send_json(
{
"token": token,
"pairing_code": pairing_code,
"expires": expiry.isoformat().replace("+00:00", "Z"),
}
@@ -123,7 +122,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
result_data["was_denied"] = was_denied
result_event.set()
await remoteauth.instance.set_notify_callback(token, on_complete)
await remoteauth.instance.set_notify_callback(pairing_code, on_complete)
# Set up async notification for action lock
locked_event = asyncio.Event()
@@ -133,7 +132,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
locked_data["action"] = action
locked_event.set()
await remoteauth.instance.set_action_locked_callback(token, on_action_locked)
await remoteauth.instance.set_action_locked_callback(
pairing_code, on_action_locked
)
# 5 minute timeout for the entire remote auth flow
timeout_seconds = 5 * 60
@@ -174,7 +175,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
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"):
if result_data.get("session_token") or result_data.get(
"reset_token"
):
response = {
"status": "authenticated",
"user_uuid": str(result_data["user_uuid"]),
@@ -204,28 +207,32 @@ async def websocket_remote_auth_request(ws: WebSocket):
if locked_wait_task in done:
# Action was locked by the authenticating device
await ws.send_json({
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 remoteauth.instance.cancel_request(pairing_code)
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)
await remoteauth.instance.update_action(
pairing_code, new_action
)
# Ignore other messages
except TimeoutError:
# 5 minute timeout reached
await remoteauth.instance.cancel_request(token)
await remoteauth.instance.cancel_request(pairing_code)
await ws.send_json(
{
"status": "timeout",
@@ -234,9 +241,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
)
except WebSocketDisconnect:
# Client disconnected, cancel the request and mark as denied
await remoteauth.instance.cancel_request(token, denied=True)
await remoteauth.instance.cancel_request(pairing_code, denied=True)
except Exception:
await remoteauth.instance.cancel_request(token)
await remoteauth.instance.cancel_request(pairing_code)
raise
finally:
# Decrement connection count
@@ -246,26 +253,21 @@ async def websocket_remote_auth_request(ws: WebSocket):
@app.websocket("/pair")
@websocket_error_handler
async def websocket_remote_auth_pair(ws: WebSocket):
"""Complete a remote authentication request using a pairing code or link token.
"""Complete a remote authentication request using a 3-word 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, or
opens the link which contains a 5-word token.
The user enters the pairing code displayed on the requesting device.
Protocol:
1. Server sends PoW challenge immediately on connect
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code
or {code: "word.word.word.word.word", pow: "<base64>"} for 5-word link token
3. Server validates PoW and code:
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
- If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}}
4. Client can then send {authenticate: true, pow: "<base64>"} to start WebAuthn
4. Client can then send {authenticate: true} to start WebAuthn
5. Server sends {optionsJSON: ...}
6. Client sends WebAuthn response
7. Server sends {status: "success", message: "..."}
Note: 5-word tokens (from links) skip the PoW requirement since generating
the link already required HARD PoW.
"""
from paskia.util import useragent
@@ -278,12 +280,14 @@ async def websocket_remote_auth_pair(ws: WebSocket):
challenge = pow.generate_challenge()
work = pow.NORMAL
await ws.send_json({
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
}
)
request = None
webauthn_challenge = None
@@ -298,10 +302,12 @@ async def websocket_remote_auth_pair(ws: WebSocket):
# Cancel the request and mark it as denied
explicitly_denied = True
await remoteauth.instance.cancel_request(request.key, denied=True)
await ws.send_json({
await ws.send_json(
{
"status": "denied",
"message": "Request denied",
})
}
)
break
# Handle authenticate request (no PoW needed - already validated during lookup)
@@ -317,7 +323,9 @@ async def websocket_remote_auth_pair(ws: WebSocket):
# Fetch and verify credential
try:
stored_cred = await db.instance.get_credential_by_id(credential.raw_id)
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}"
@@ -388,18 +396,18 @@ async def websocket_remote_auth_pair(ws: WebSocket):
else:
msg += " The other device is now logged in."
await ws.send_json({
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
# Validate PoW for pairing codes
solution_b64 = msg.get("pow")
if not solution_b64:
raise ValueError("PoW solution required")
@@ -414,37 +422,38 @@ async def websocket_remote_auth_pair(ws: WebSocket):
except ValueError as e:
# Invalid PoW - send new challenge
challenge = pow.generate_challenge()
await ws.send_json({
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:
# Look up the remote auth request by pairing code
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({
await ws.send_json(
{
"status": 404,
"detail": "Code not found",
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
})
)
request = None # Reset for next attempt
continue
@@ -453,31 +462,37 @@ async def websocket_remote_auth_pair(ws: WebSocket):
locked_action = await remoteauth.instance.lock_action(request.key)
if locked_action is None:
# Already locked by another device
await ws.send_json({
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
request.action = locked_action # Update local copy with locked value
# Send device info to the authenticating device
await ws.send_json({
await ws.send_json(
{
"status": "found",
"host": request.host,
"user_agent_pretty": useragent.compact_user_agent(request.user_agent),
"user_agent_pretty": useragent.compact_user_agent(
request.user_agent
),
"client_ip": request.ip,
"action": request.action,
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
}
})
)
except Exception:
# If websocket disconnects without explicit denial, unlock the request
if request and not explicitly_denied:
+1 -6
View File
@@ -5,19 +5,14 @@ from fastapi import FastAPI, WebSocket
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.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 passphrase
from paskia.util.tokens import create_token, session_key
# Create a FastAPI subapp for WebSocket endpoints
app = FastAPI()
# Mount the remote auth subapp
from paskia.fastapi import remote
app.mount("/remote-auth", remote.app)
async def register_chat(
ws: WebSocket,
+6 -4
View File
@@ -3,10 +3,9 @@ Shared WebSocket utilities for FastAPI endpoints.
"""
import logging
import base64url
from functools import wraps
import base64url
from fastapi import WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
@@ -17,6 +16,7 @@ from paskia.util import pow
def websocket_error_handler(func):
"""Decorator for WebSocket endpoints that handles common errors."""
@wraps(func)
async def wrapper(ws: WebSocket, *args, **kwargs):
try:
@@ -57,12 +57,14 @@ async def require_pow(ws: WebSocket, work: int | None = None) -> None:
if work is None:
work = pow.DEFAULT_WORK
await ws.send_json({
await ws.send_json(
{
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
}
})
}
)
response = await ws.receive_json()
solution_b64 = response.get("pow")
+28 -68
View File
@@ -29,16 +29,12 @@ 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 (first 3 words of the token)
PAIRING_CODE_WORDS = 3
@dataclass
class RemoteAuthRequest:
"""A pending remote authentication request."""
key: str # The passphrase token (5 words)
pairing_code: str # First 3 words of the token for manual entry
key: str # The 3-word passphrase code
created_at: datetime
host: str # The host where the session should be created
ip: str # IP of the requesting device
@@ -47,7 +43,9 @@ class RemoteAuthRequest:
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, reset_token) or (None, None, None, None) on cancel/expire
notify: Callable[[str | None, UUID | None, UUID | None, str | None], None] | None = None
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
@@ -60,20 +58,11 @@ class RemoteAuthRequest:
reset_token: str | None = None
def _generate_pairing_code() -> str:
"""Generate a short, easy-to-communicate pairing code using words.
DEPRECATED: Now we use the first 3 words of the main token instead.
"""
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 (first 3 words) -> token
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
@@ -113,8 +102,6 @@ class RemoteAuthManager:
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, None)
@@ -127,34 +114,31 @@ class RemoteAuthManager:
ip: str,
user_agent: str,
action: str = "login",
) -> tuple[str, str, datetime]:
) -> tuple[str, datetime]:
"""Create a new remote auth request.
The token is a 5-word passphrase. The first 3 words serve as the pairing code.
We ensure the pairing code (first 3 words) is unique across concurrent requests
by regenerating the token if there's a collision.
The code is a 3-word passphrase.
We ensure uniqueness across concurrent requests.
Returns:
(token, pairing_code, expiry) - The passphrase token, pairing code (first 3 words), and expiration time
(code, expiry) - The 3-word passphrase code and expiration time
"""
now = datetime.now(timezone.utc)
expiry = now + REMOTE_AUTH_LIFETIME
async with self._lock:
# Generate token with unique 3-word prefix
# Generate unique 3-word code
max_attempts = 100
for _ in range(max_attempts):
token = passphrase.generate()
pairing_code = passphrase.prefix(token, n=PAIRING_CODE_WORDS)
if pairing_code not in self._by_pairing_code:
code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
if code not in self._requests:
break
else:
# Extremely unlikely but handle gracefully
raise ValueError("Unable to generate unique pairing code")
raise ValueError("Unable to generate unique code")
request = RemoteAuthRequest(
key=token,
pairing_code=pairing_code,
key=code,
created_at=now,
host=host,
ip=ip,
@@ -162,47 +146,24 @@ class RemoteAuthManager:
action=action,
)
self._requests[token] = request
self._by_pairing_code[pairing_code] = token
self._requests[code] = request
return token, pairing_code, expiry
return 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."""
async def get_request(self, code: str) -> RemoteAuthRequest | None:
"""Get a pending request by 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):
if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
return None
async with self._lock:
token = self._by_pairing_code.get(normalized)
if token is None:
return None
req = self._requests.get(token)
req = self._requests.get(normalized)
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)
del self._requests[normalized]
return None
return req
@@ -298,8 +259,6 @@ class RemoteAuthManager:
req = self._requests.pop(token, None)
if req is None:
return False
# Remove from pairing code index
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify:
try:
req.notify(session_token, user_uuid, credential_uuid, reset_token)
@@ -307,7 +266,9 @@ class RemoteAuthManager:
pass
return True
async def cancel_request(self, token: str, *, denied: bool = False) -> RemoteAuthRequest | None:
async def cancel_request(
self, token: str, *, denied: bool = False
) -> RemoteAuthRequest | None:
"""Cancel and remove a request.
Args:
@@ -320,7 +281,6 @@ class RemoteAuthManager:
req = self._requests.pop(token, None)
if req is None:
return None
self._by_pairing_code.pop(req.pairing_code, None)
if denied:
req.denied = True
if req.notify and not req.completed:
@@ -340,15 +300,15 @@ class RemoteAuthManager:
This is used to determine PoW difficulty based on load.
"""
# Count is maintained externally by the WebSocket endpoints
return getattr(self, '_ws_count', 0)
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
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)
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.
@@ -366,7 +326,7 @@ class RemoteAuthManager:
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):
if not passphrase.is_well_formed(token, n=passphrase.N_WORDS_SHORT):
return None
async with self._lock:
req = self._requests.get(token)
+1 -5
View File
@@ -3,6 +3,7 @@ import secrets
from paskia.util.wordlist import words
N_WORDS = 5
N_WORDS_SHORT = 3
wset = set(words)
@@ -17,8 +18,3 @@ def is_well_formed(passphrase: str, n=N_WORDS, sep=".") -> bool:
"""Check if the passphrase is well-formed according to the regex pattern."""
p = passphrase.split(sep)
return len(p) == n and all(w in wset for w in passphrase.split("."))
def prefix(passphrase: str, n=3, sep=".") -> str:
"""Extract the first n words from a passphrase."""
return sep.join(passphrase.split(sep)[:n])
+1
View File
@@ -13,6 +13,7 @@ EASY = 2 # Around 0.25s
NORMAL = 8 # Around 1s
HARD = 32 # Around 4s
def generate_challenge() -> bytes:
"""Generate a random 8-byte challenge."""
return secrets.token_bytes(8)
+1
View File
@@ -79,6 +79,7 @@ dev = [
"pytest>=9.0.1",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"ruff>=0.14.8",
]
[project.scripts]
+7 -7
View File
@@ -27,7 +27,6 @@ import os
import shutil
import signal
import subprocess
import sys
from pathlib import Path
from sys import stderr
from threading import Thread
@@ -203,8 +202,7 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
else:
site_addr = f"{scheme}://{host}:{port}"
block = (
CADDYFILE_SITE_BLOCK
.replace("SITE_ADDR", site_addr)
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
.replace("BACKEND_PORT", str(BACKEND_PORT))
.replace("VITE_PORT", str(vite_port))
)
@@ -275,12 +273,13 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
# Read stderr line by line until Caddy signals it's ready or exits
# Caddy outputs logs; "serving initial configuration" means it's ready
ready = False
while True:
exit_code = caddy_process.poll()
if exit_code is not None:
# Process exited - read remaining stderr and report failure
remaining = caddy_process.stderr.read().decode() if caddy_process.stderr else ""
remaining = (
caddy_process.stderr.read().decode() if caddy_process.stderr else ""
)
if remaining:
for line in remaining.splitlines():
if line:
@@ -303,7 +302,6 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
# Check for ready signal
if "serving initial configuration" in line:
ready = True
break
parsed = parse_caddy_log(line)
@@ -367,7 +365,9 @@ def main():
parser.add_argument("hostport", nargs="?", default=None)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument("--origin", action="append", dest="origins", help="Allowed origin(s)")
parser.add_argument(
"--origin", action="append", dest="origins", help="Allowed origin(s)"
)
parser.add_argument("--auth-host", help="Dedicated auth host")
args, remaining = parser.parse_known_args()