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
+7 -82
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="pairing-entry"> <div class="pairing-entry">
<form @submit.prevent="submitCode" class="pairing-form"> <form @submit.prevent="submitCode" class="pairing-form">
<!-- Code input (only shown in pairing mode, not token mode) --> <!-- Code input (shown when device info not yet received) -->
<div v-if="!deviceInfo && !props.token" class="input-row"> <div v-if="!deviceInfo" class="input-row">
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }"> <div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError, 'focused': isFocused }">
<!-- Visual slot-machine display overlay --> <!-- Visual slot-machine display overlay -->
<div class="slot-machine" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError }" aria-hidden="true"> <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' }, 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.' }, description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
placeholder: { type: String, default: 'Enter three words' }, placeholder: { type: String, default: 'Enter three words' },
action: { type: String, default: 'login' }, // 'login' or 'register' 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']) const emit = defineEmits(['completed', 'error', 'cancelled', 'back', 'register', 'deviceInfoVisible'])
@@ -591,7 +590,7 @@ async function submitCode() {
const res = await ws.receive_json() const res = await ws.receive_json()
if (typeof res.status === 'number' && res.status >= 400) throw new Error(res.detail || 'Authentication failed') 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') 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) ws.send_json(authResponse)
const result = await ws.receive_json() const result = await ws.receive_json()
if (typeof result.status === 'number' && result.status >= 400) throw new Error(result.detail || 'Authentication failed') 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() { async function deny() {
// Send deny message to server before closing websocket // Send deny message to server before closing websocket
if (ws) { if (ws) {
@@ -694,18 +629,8 @@ async function deny() {
ws = null ws = null
} }
// In token mode (standalone link), try to close the window // Reset to initial state
if (props.token) { reset()
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() { function reset() {
@@ -741,7 +666,7 @@ onUnmounted(() => {
if (ws) { ws.close(); ws = null } if (ws) { ws.close(); ws = null }
}) })
defineExpose({ reset, deny, code, handleInput, authenticateWithToken, loading, error }) defineExpose({ reset, deny, code, handleInput, loading, error })
</script> </script>
<style scoped> <style scoped>
+7 -164
View File
@@ -23,19 +23,15 @@
</div> </div>
<p class="site-url">{{ siteUrlDisplay }}</p> <p class="site-url">{{ siteUrlDisplay }}</p>
</div> </div>
<div class="qr-section">
<div class="qr-code qr-placeholder"></div>
</div>
</div> </div>
<div class="waiting-indicator"> <div class="waiting-indicator">
<div class="spinner-small"></div> <div class="spinner-small"></div>
<span>Generating secure link</span> <span>Generating code</span>
</div> </div>
</div> </div>
<!-- Waiting/Authenticating phase - show codes and QR --> <!-- Waiting/Authenticating phase - show codes -->
<div v-else class="auth-display"> <div v-else class="auth-display">
<div class="auth-content"> <div class="auth-content">
<div v-if="pairingCode" class="pairing-code-section"> <div v-if="pairingCode" class="pairing-code-section">
@@ -47,16 +43,6 @@
</div> </div>
<p class="site-url">{{ siteUrlDisplay }}</p> <p class="site-url">{{ siteUrlDisplay }}</p>
</div> </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>
<div class="waiting-indicator"> <div class="waiting-indicator">
@@ -68,8 +54,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode/lib/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url' import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
@@ -82,18 +67,13 @@ const props = defineProps({
const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register']) const emit = defineEmits(['authenticated', 'cancelled', 'error', 'register'])
const url = ref(null)
const pairingCode = ref(null) const pairingCode = ref(null)
const expires = ref(null)
const qrCanvas = ref(null)
const completed = ref(false) const completed = ref(false)
const error = ref(null) const error = ref(null)
const phase = ref('connecting') const phase = ref('connecting')
const settings = ref(null) const settings = ref(null)
const showCopyToast = ref(false)
const animatedWords = ref(['', '', '']) const animatedWords = ref(['', '', ''])
let ws = null let ws = null
let copyToastTimer = null
let wordAnimationTimer = null let wordAnimationTimer = null
const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '') const displayCode = computed(() => pairingCode.value ? pairingCode.value.replace(/\./g, ' ') : '')
@@ -167,9 +147,7 @@ function stopWordAnimation() {
async function startRemoteAuth() { async function startRemoteAuth() {
error.value = null error.value = null
completed.value = false completed.value = false
url.value = null
pairingCode.value = null pairingCode.value = null
expires.value = null
phase.value = 'connecting' phase.value = 'connecting'
// Start word animation // Start word animation
@@ -191,27 +169,20 @@ async function startRemoteAuth() {
ws.send_json({ pow: b64enc(nonces), action: 'login' }) 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() const res = await ws.receive_json()
if (res.status) { if (res.status) {
throw new Error(res.detail || `Failed to create remote auth request: ${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 pairingCode.value = res.pairing_code
expires.value = res.expires
// Stop word animation // Stop word animation
stopWordAnimation() stopWordAnimation()
phase.value = 'waiting' phase.value = 'waiting'
await nextTick()
drawQR()
// Wait for authentication // Wait for authentication
while (true) { while (true) {
const msg = await ws.receive_json() 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() { function retry() {
startRemoteAuth() startRemoteAuth()
} }
@@ -301,7 +238,7 @@ function cancel() {
} }
watch(() => props.active, (newVal) => { watch(() => props.active, (newVal) => {
if (newVal && !url.value && !error.value && !completed.value) { if (newVal && !pairingCode.value && !error.value && !completed.value) {
startRemoteAuth() startRemoteAuth()
} }
}) })
@@ -317,9 +254,6 @@ onUnmounted(() => {
ws.close() ws.close()
ws = null ws = null
} }
if (copyToastTimer) {
clearTimeout(copyToastTimer)
}
stopWordAnimation() stopWordAnimation()
}) })
@@ -519,66 +453,7 @@ defineExpose({ retry, cancel })
opacity: 0.8; opacity: 0.8;
} }
.qr-section { .waiting-indicator {
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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -632,33 +507,6 @@ defineExpose({ retry, cancel })
color: var(--color-error, #ef4444); 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 */ /* Responsive adjustments */
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-content { .auth-content {
@@ -667,15 +515,10 @@ defineExpose({ retry, cancel })
align-items: center; align-items: center;
} }
.pairing-code-section, .pairing-code-section {
.qr-section {
width: 100%; width: 100%;
max-width: 280px; max-width: 280px;
} }
.qr-section {
max-width: 180px;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
+1 -46
View File
@@ -47,28 +47,6 @@
@error="handleRemoteAuthError" @error="handleRemoteAuthError"
/> />
</div> </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> </div>
</section> </section>
</div> </div>
@@ -82,17 +60,12 @@ import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api'
import RemoteAuthInline from '@/components/RemoteAuthRequest.vue' import RemoteAuthInline from '@/components/RemoteAuthRequest.vue'
import RemoteAuth from '@/components/RemoteAuthPermit.vue'
const props = defineProps({ const props = defineProps({
mode: { mode: {
type: String, type: String,
default: 'login', default: 'login',
validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) 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 settings = ref(null)
const userInfo = ref(null) const userInfo = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden' const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local', 'remote', or 'complete' const authView = ref('local') // 'local' or 'remote'
const remoteAuthRef = ref(null)
let statusTimer = null let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated) const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
@@ -118,9 +90,6 @@ const canAuthenticate = computed(() => {
}) })
const headingTitle = computed(() => { const headingTitle = computed(() => {
if (authView.value === 'complete') {
return `🔐 ${settings.value?.rp_name || location.origin}`
}
if (props.mode === 'reauth') { if (props.mode === 'reauth') {
return `🔐 Additional Authentication` return `🔐 Additional Authentication`
} }
@@ -129,9 +98,6 @@ const headingTitle = computed(() => {
}) })
const headerMessage = computed(() => { const headerMessage = computed(() => {
if (authView.value === 'complete') {
return 'Complete the login request from another device.'
}
if (props.mode === 'reauth') { if (props.mode === 'reauth') {
return 'Please verify your identity to continue with this action.' 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 // 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) { function handleHeaderLinkClick(event) {
const target = event.target const target = event.target
if (target.tagName === 'A' && target.classList.contains('inline-link')) { if (target.tagName === 'A' && target.classList.contains('inline-link')) {
@@ -300,11 +260,6 @@ onMounted(async () => {
await fetchUserInfo() await fetchUserInfo()
initializing.value = false 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 // Add click handler for inline links
document.addEventListener('click', handleHeaderLinkClick) document.addEventListener('click', handleHeaderLinkClick)
}) })
+5 -43
View File
@@ -13,7 +13,6 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from paskia import remoteauth
from paskia.authsession import ( from paskia.authsession import (
EXPIRES, EXPIRES,
get_reset, 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.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.globals import db from paskia.globals import db
from paskia.globals import passkey as global_passkey 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 from paskia.util.tokens import session_key
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@@ -209,31 +208,16 @@ async def get_settings():
@app.get("/token-info") @app.get("/token-info")
async def api_token_info(token: str): async def api_token_info(token: str):
"""Get information about a token (remote auth or reset token). """Get information about a reset token.
This endpoint allows the frontend to determine what type of token it is
dealing with and get relevant information for display.
Returns: Returns:
- type: "remote_auth" or "reset" - type: "reset"
- For remote_auth: host, user_agent_pretty (requesting device info) - user_name: display name of the user
- For reset: user info (display name, etc.) - token_type: type of reset token
""" """
if not passphrase.is_well_formed(token): if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404, detail="Invalid 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 # Check if this is a reset token
try: try:
reset_token = await get_reset(token) 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") 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") @app.post("/user-info")
async def api_user_info( async def api_user_info(
request: Request, 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.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from paskia import remoteauth
from paskia.fastapi import admin, api, auth_host, ws from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import frontend, hostutil, passphrase from paskia.util import frontend, hostutil, passphrase
@@ -38,8 +37,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
origins=config["origins"], origins=config["origins"],
bootstrap=False, bootstrap=False,
) )
# Initialize remote authentication manager
await remoteauth.init()
except ValueError as e: except ValueError as e:
logging.error(f"⚠️ {e}") logging.error(f"⚠️ {e}")
# Re-raise to fail fast # Re-raise to fail fast
@@ -52,9 +49,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
yield yield
# Shutdown cleanup
await remoteauth.shutdown()
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -126,22 +120,11 @@ async def examples_page():
@app.get("/{token}") @app.get("/{token}")
@app.get("/auth/{token}") @app.get("/auth/{token}")
async def token_link(token: str): 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: The frontend will validate the token via /auth/api/token-info.
- 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.
""" """
if not passphrase.is_well_formed(token): if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404) 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")) return Response(*await frontend.read("/int/reset/index.html"))
+109 -94
View File
@@ -13,7 +13,6 @@ import asyncio
from uuid import UUID from uuid import UUID
import base64url import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import remoteauth from paskia import remoteauth
@@ -21,8 +20,7 @@ from paskia.authsession import create_session
from paskia.fastapi.session import infodict from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import db, passkey 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 # Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI() app = FastAPI()
@@ -39,7 +37,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
Flow: Flow:
1. Client connects 1. Client connects
2. Server sends HARD PoW challenge, client solves and responds 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 4. Server waits for another device to authenticate via /remote-auth/pair
5. When auth completes, server sends session_token to this client 5. When auth completes, server sends session_token to this client
6. Client can then use the session token to set a cookie 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() challenge = pow.generate_challenge()
work = remoteauth.instance.get_pow_difficulty() work = remoteauth.instance.get_pow_difficulty()
await ws.send_json({ await ws.send_json(
"pow": { {
"challenge": base64url.enc(challenge), "pow": {
"work": work, "challenge": base64url.enc(challenge),
"work": work,
}
} }
}) )
# Receive client response with PoW solution and action # Receive client response with PoW solution and action
response = await ws.receive_json() response = await ws.receive_json()
@@ -88,17 +88,16 @@ async def websocket_remote_auth_request(ws: WebSocket):
metadata = infodict(ws, "remote-auth-request") metadata = infodict(ws, "remote-auth-request")
# Create the 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, host=host,
ip=metadata.get("ip") or "", ip=metadata.get("ip") or "",
user_agent=metadata.get("user_agent") or "", user_agent=metadata.get("user_agent") or "",
action=action, 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( await ws.send_json(
{ {
"token": token,
"pairing_code": pairing_code, "pairing_code": pairing_code,
"expires": expiry.isoformat().replace("+00:00", "Z"), "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_data["was_denied"] = was_denied
result_event.set() 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 # Set up async notification for action lock
locked_event = asyncio.Event() locked_event = asyncio.Event()
@@ -133,7 +132,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
locked_data["action"] = action locked_data["action"] = action
locked_event.set() 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 # 5 minute timeout for the entire remote auth flow
timeout_seconds = 5 * 60 timeout_seconds = 5 * 60
@@ -174,7 +175,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
if result_wait_task in done: if result_wait_task in done:
# Authentication completed (or expired/cancelled/denied) # Authentication completed (or expired/cancelled/denied)
was_denied = result_data.get("was_denied", False) 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 = { response = {
"status": "authenticated", "status": "authenticated",
"user_uuid": str(result_data["user_uuid"]), "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: if locked_wait_task in done:
# Action was locked by the authenticating device # Action was locked by the authenticating device
await ws.send_json({ await ws.send_json(
"status": "locked", {
"action": locked_data.get("action", "login"), "status": "locked",
}) "action": locked_data.get("action", "login"),
}
)
# Continue waiting for result # Continue waiting for result
if receive_task in done: if receive_task in done:
# Client sent a message # Client sent a message
msg = receive_task.result() msg = receive_task.result()
if msg.get("action") == "cancel": if msg.get("action") == "cancel":
await remoteauth.instance.cancel_request(token) await remoteauth.instance.cancel_request(pairing_code)
await ws.send_json({"status": "cancelled"}) await ws.send_json({"status": "cancelled"})
return return
elif msg.get("action") == "update_action": elif msg.get("action") == "update_action":
# Update the action (login/register) if not locked # Update the action (login/register) if not locked
new_action = "register" if msg.get("register") else "login" 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 # Ignore other messages
except TimeoutError: except TimeoutError:
# 5 minute timeout reached # 5 minute timeout reached
await remoteauth.instance.cancel_request(token) await remoteauth.instance.cancel_request(pairing_code)
await ws.send_json( await ws.send_json(
{ {
"status": "timeout", "status": "timeout",
@@ -234,9 +241,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
) )
except WebSocketDisconnect: except WebSocketDisconnect:
# Client disconnected, cancel the request and mark as denied # 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: except Exception:
await remoteauth.instance.cancel_request(token) await remoteauth.instance.cancel_request(pairing_code)
raise raise
finally: finally:
# Decrement connection count # Decrement connection count
@@ -246,26 +253,21 @@ async def websocket_remote_auth_request(ws: WebSocket):
@app.websocket("/pair") @app.websocket("/pair")
@websocket_error_handler @websocket_error_handler
async def websocket_remote_auth_pair(ws: WebSocket): 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. 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 The user enters the pairing code displayed on the requesting device.
opens the link which contains a 5-word token.
Protocol: Protocol:
1. Server sends PoW challenge immediately on connect 1. Server sends PoW challenge immediately on connect
2. Client sends {code: "word.word.word", pow: "<base64>"} for 3-word pairing code 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: 3. Server validates PoW and code:
- If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}} - If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}}
- If valid: {status: "found", host: "...", user_agent_pretty: "...", 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: ...} 5. Server sends {optionsJSON: ...}
6. Client sends WebAuthn response 6. Client sends WebAuthn response
7. Server sends {status: "success", message: "..."} 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 from paskia.util import useragent
@@ -278,12 +280,14 @@ async def websocket_remote_auth_pair(ws: WebSocket):
challenge = pow.generate_challenge() challenge = pow.generate_challenge()
work = pow.NORMAL work = pow.NORMAL
await ws.send_json({ await ws.send_json(
"pow": { {
"challenge": base64url.enc(challenge), "pow": {
"work": work, "challenge": base64url.enc(challenge),
"work": work,
}
} }
}) )
request = None request = None
webauthn_challenge = None webauthn_challenge = None
@@ -298,10 +302,12 @@ async def websocket_remote_auth_pair(ws: WebSocket):
# Cancel the request and mark it as denied # Cancel the request and mark it as denied
explicitly_denied = True explicitly_denied = True
await remoteauth.instance.cancel_request(request.key, denied=True) await remoteauth.instance.cancel_request(request.key, denied=True)
await ws.send_json({ await ws.send_json(
"status": "denied", {
"message": "Request denied", "status": "denied",
}) "message": "Request denied",
}
)
break break
# Handle authenticate request (no PoW needed - already validated during lookup) # 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 # Fetch and verify credential
try: 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: except ValueError:
raise ValueError( raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}" f"This passkey is no longer registered with {passkey.instance.rp_name}"
@@ -388,63 +396,64 @@ async def websocket_remote_auth_pair(ws: WebSocket):
else: else:
msg += " The other device is now logged in." msg += " The other device is now logged in."
await ws.send_json({ await ws.send_json(
"status": "success", {
"message": msg, "status": "success",
}) "message": msg,
}
)
break break
# Handle code lookup request - requires PoW validation # Handle code lookup request - requires PoW validation
code = msg.get("code", "") code = msg.get("code", "")
is_link_token = len(code.split(".")) == 5
if not is_link_token: # Validate PoW for pairing codes
# Validate PoW for 3-word pairing codes solution_b64 = msg.get("pow")
solution_b64 = msg.get("pow") if not solution_b64:
if not solution_b64: raise ValueError("PoW solution required")
raise ValueError("PoW solution required")
try: try:
solution = base64url.dec(solution_b64) solution = base64url.dec(solution_b64)
except Exception: except Exception:
raise ValueError("Invalid PoW solution encoding") raise ValueError("Invalid PoW solution encoding")
try: try:
pow.verify_pow(challenge, solution, work) pow.verify_pow(challenge, solution, work)
except ValueError as e: except ValueError as e:
# Invalid PoW - send new challenge # Invalid PoW - send new challenge
challenge = pow.generate_challenge() challenge = pow.generate_challenge()
await ws.send_json({ await ws.send_json(
{
"status": 400, "status": 400,
"detail": str(e), "detail": str(e),
"pow": { "pow": {
"challenge": base64url.enc(challenge), "challenge": base64url.enc(challenge),
"work": work, "work": work,
} },
}) }
continue )
continue
if not code: if not code:
raise ValueError("Pairing code required") raise ValueError("Pairing code required")
# Look up the remote auth request by pairing code or token # Look up the remote auth request by pairing code
if is_link_token: request = await remoteauth.instance.get_request(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) # Generate new challenge for next request (always NORMAL for authenticated users)
challenge = pow.generate_challenge() challenge = pow.generate_challenge()
if request is None: if request is None:
await ws.send_json({ await ws.send_json(
"status": 404, {
"detail": "Code not found", "status": 404,
"pow": { "detail": "Code not found",
"challenge": base64url.enc(challenge), "pow": {
"work": work, "challenge": base64url.enc(challenge),
"work": work,
},
} }
}) )
request = None # Reset for next attempt request = None # Reset for next attempt
continue continue
@@ -453,31 +462,37 @@ async def websocket_remote_auth_pair(ws: WebSocket):
locked_action = await remoteauth.instance.lock_action(request.key) locked_action = await remoteauth.instance.lock_action(request.key)
if locked_action is None: if locked_action is None:
# Already locked by another device # 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", "status": 409,
"pow": { "detail": "This request is already being processed in another window",
"challenge": base64url.enc(challenge), "pow": {
"work": work, "challenge": base64url.enc(challenge),
"work": work,
},
} }
}) )
request = None # Reset for next attempt request = None # Reset for next attempt
continue continue
request.action = locked_action # Update local copy with locked value request.action = locked_action # Update local copy with locked value
# Send device info to the authenticating device # Send device info to the authenticating device
await ws.send_json({ await ws.send_json(
"status": "found", {
"host": request.host, "status": "found",
"user_agent_pretty": useragent.compact_user_agent(request.user_agent), "host": request.host,
"client_ip": request.ip, "user_agent_pretty": useragent.compact_user_agent(
"action": request.action, request.user_agent
"pow": { ),
"challenge": base64url.enc(challenge), "client_ip": request.ip,
"work": work, "action": request.action,
"pow": {
"challenge": base64url.enc(challenge),
"work": work,
},
} }
}) )
except Exception: except Exception:
# If websocket disconnects without explicit denial, unlock the request # If websocket disconnects without explicit denial, unlock the request
if request and not explicitly_denied: 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.authsession import create_session, get_reset, get_session
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.session import AUTH_COOKIE, infodict 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.globals import db, passkey
from paskia.util import passphrase from paskia.util import passphrase
from paskia.util.tokens import create_token, session_key from paskia.util.tokens import create_token, session_key
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
app = FastAPI() app = FastAPI()
# Mount the remote auth subapp
from paskia.fastapi import remote
app.mount("/remote-auth", remote.app)
async def register_chat( async def register_chat(
ws: WebSocket, ws: WebSocket,
+9 -7
View File
@@ -3,10 +3,9 @@ Shared WebSocket utilities for FastAPI endpoints.
""" """
import logging import logging
import base64url
from functools import wraps from functools import wraps
import base64url
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse from webauthn.helpers.exceptions import InvalidAuthenticationResponse
@@ -17,6 +16,7 @@ from paskia.util import pow
def websocket_error_handler(func): def websocket_error_handler(func):
"""Decorator for WebSocket endpoints that handles common errors.""" """Decorator for WebSocket endpoints that handles common errors."""
@wraps(func) @wraps(func)
async def wrapper(ws: WebSocket, *args, **kwargs): async def wrapper(ws: WebSocket, *args, **kwargs):
try: try:
@@ -57,12 +57,14 @@ async def require_pow(ws: WebSocket, work: int | None = None) -> None:
if work is None: if work is None:
work = pow.DEFAULT_WORK work = pow.DEFAULT_WORK
await ws.send_json({ await ws.send_json(
"pow": { {
"challenge": base64url.enc(challenge), "pow": {
"work": work, "challenge": base64url.enc(challenge),
"work": work,
}
} }
}) )
response = await ws.receive_json() response = await ws.receive_json()
solution_b64 = response.get("pow") 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 requests expire after this duration
REMOTE_AUTH_LIFETIME = timedelta(minutes=5) 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 @dataclass
class RemoteAuthRequest: class RemoteAuthRequest:
"""A pending remote authentication request.""" """A pending remote authentication request."""
key: str # The passphrase token (5 words) key: str # The 3-word passphrase code
pairing_code: str # First 3 words of the token for manual entry
created_at: datetime created_at: datetime
host: str # The host where the session should be created host: str # The host where the session should be created
ip: str # IP of the requesting device 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 locked: bool = False # True once the authenticating device has entered the code
# Callback to notify the requesting device when auth completes # 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 # 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 # Callback to notify the requesting device when action is locked
# Takes (action) to confirm what action was locked # Takes (action) to confirm what action was locked
action_locked_notify: Callable[[str], None] | None = None action_locked_notify: Callable[[str], None] | None = None
@@ -60,20 +58,11 @@ class RemoteAuthRequest:
reset_token: str | None = None 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: class RemoteAuthManager:
"""Manages pending remote authentication requests.""" """Manages pending remote authentication requests."""
def __init__(self): def __init__(self):
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token self._requests: dict[str, RemoteAuthRequest] = {} # keyed by 3-word code
self._by_pairing_code: dict[str, str] = {} # pairing_code (first 3 words) -> token
self._cleanup_task: asyncio.Task | None = None self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@@ -113,8 +102,6 @@ class RemoteAuthManager:
expired_keys.append(key) expired_keys.append(key)
for key in expired_keys: for key in expired_keys:
req = self._requests.pop(key) 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: if req.notify and not req.completed:
try: try:
req.notify(None, None, None, None) req.notify(None, None, None, None)
@@ -127,34 +114,31 @@ class RemoteAuthManager:
ip: str, ip: str,
user_agent: str, user_agent: str,
action: str = "login", action: str = "login",
) -> tuple[str, str, datetime]: ) -> tuple[str, datetime]:
"""Create a new remote auth request. """Create a new remote auth request.
The token is a 5-word passphrase. The first 3 words serve as the pairing code. The code is a 3-word passphrase.
We ensure the pairing code (first 3 words) is unique across concurrent requests We ensure uniqueness across concurrent requests.
by regenerating the token if there's a collision.
Returns: 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) now = datetime.now(timezone.utc)
expiry = now + REMOTE_AUTH_LIFETIME expiry = now + REMOTE_AUTH_LIFETIME
async with self._lock: async with self._lock:
# Generate token with unique 3-word prefix # Generate unique 3-word code
max_attempts = 100 max_attempts = 100
for _ in range(max_attempts): for _ in range(max_attempts):
token = passphrase.generate() code = passphrase.generate(n=passphrase.N_WORDS_SHORT)
pairing_code = passphrase.prefix(token, n=PAIRING_CODE_WORDS) if code not in self._requests:
if pairing_code not in self._by_pairing_code:
break break
else: else:
# Extremely unlikely but handle gracefully # Extremely unlikely but handle gracefully
raise ValueError("Unable to generate unique pairing code") raise ValueError("Unable to generate unique code")
request = RemoteAuthRequest( request = RemoteAuthRequest(
key=token, key=code,
pairing_code=pairing_code,
created_at=now, created_at=now,
host=host, host=host,
ip=ip, ip=ip,
@@ -162,47 +146,24 @@ class RemoteAuthManager:
action=action, action=action,
) )
self._requests[token] = request self._requests[code] = request
self._by_pairing_code[pairing_code] = token
return token, pairing_code, expiry return code, expiry
async def get_request(self, token: str) -> RemoteAuthRequest | None: async def get_request(self, code: str) -> RemoteAuthRequest | None:
"""Get a pending request by token, if valid and not expired.""" """Get a pending request by code, 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 # Normalize: lowercase, dot-separated words
normalized = code.lower().strip().replace(" ", ".") normalized = code.lower().strip().replace(" ", ".")
# Validate it's a well-formed short passphrase if not passphrase.is_well_formed(normalized, n=passphrase.N_WORDS_SHORT):
if not passphrase.is_well_formed(normalized, n=PAIRING_CODE_WORDS):
return None return None
async with self._lock: async with self._lock:
token = self._by_pairing_code.get(normalized) req = self._requests.get(normalized)
if token is None:
return None
req = self._requests.get(token)
if req is None: if req is None:
self._by_pairing_code.pop(normalized, None)
return None return None
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
if now > req.created_at + REMOTE_AUTH_LIFETIME: if now > req.created_at + REMOTE_AUTH_LIFETIME:
# Expired # Expired
del self._requests[token] del self._requests[normalized]
self._by_pairing_code.pop(normalized, None)
return None return None
return req return req
@@ -298,8 +259,6 @@ class RemoteAuthManager:
req = self._requests.pop(token, None) req = self._requests.pop(token, None)
if req is None: if req is None:
return False return False
# Remove from pairing code index
self._by_pairing_code.pop(req.pairing_code, None)
if req.notify: if req.notify:
try: try:
req.notify(session_token, user_uuid, credential_uuid, reset_token) req.notify(session_token, user_uuid, credential_uuid, reset_token)
@@ -307,7 +266,9 @@ class RemoteAuthManager:
pass pass
return True 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. """Cancel and remove a request.
Args: Args:
@@ -320,7 +281,6 @@ class RemoteAuthManager:
req = self._requests.pop(token, None) req = self._requests.pop(token, None)
if req is None: if req is None:
return None return None
self._by_pairing_code.pop(req.pairing_code, None)
if denied: if denied:
req.denied = True req.denied = True
if req.notify and not req.completed: if req.notify and not req.completed:
@@ -340,15 +300,15 @@ class RemoteAuthManager:
This is used to determine PoW difficulty based on load. This is used to determine PoW difficulty based on load.
""" """
# Count is maintained externally by the WebSocket endpoints # 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: def increment_connections(self) -> None:
"""Increment the WebSocket connection counter.""" """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: def decrement_connections(self) -> None:
"""Decrement the WebSocket connection counter.""" """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: def get_pow_difficulty(self) -> int:
"""Get PoW difficulty based on current WebSocket connection count. """Get PoW difficulty based on current WebSocket connection count.
@@ -366,7 +326,7 @@ class RemoteAuthManager:
async def consume_request(self, token: str) -> RemoteAuthRequest | None: async def consume_request(self, token: str) -> RemoteAuthRequest | None:
"""Get and remove a request (for use by the authenticating device).""" """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 return None
async with self._lock: async with self._lock:
req = self._requests.get(token) req = self._requests.get(token)
+1 -5
View File
@@ -3,6 +3,7 @@ import secrets
from paskia.util.wordlist import words from paskia.util.wordlist import words
N_WORDS = 5 N_WORDS = 5
N_WORDS_SHORT = 3
wset = set(words) 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.""" """Check if the passphrase is well-formed according to the regex pattern."""
p = passphrase.split(sep) p = passphrase.split(sep)
return len(p) == n and all(w in wset for w in passphrase.split(".")) 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])
+3 -2
View File
@@ -9,9 +9,10 @@ All valid nonces are concatenated into a solution for server verification.
import hashlib import hashlib
import secrets import secrets
EASY = 2 # Around 0.25s EASY = 2 # Around 0.25s
NORMAL = 8 # Around 1s NORMAL = 8 # Around 1s
HARD = 32 # Around 4s HARD = 32 # Around 4s
def generate_challenge() -> bytes: def generate_challenge() -> bytes:
"""Generate a random 8-byte challenge.""" """Generate a random 8-byte challenge."""
+1
View File
@@ -79,6 +79,7 @@ dev = [
"pytest>=9.0.1", "pytest>=9.0.1",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0", "pytest-cov>=7.0.0",
"ruff>=0.14.8",
] ]
[project.scripts] [project.scripts]
+7 -7
View File
@@ -27,7 +27,6 @@ import os
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from sys import stderr from sys import stderr
from threading import Thread from threading import Thread
@@ -203,8 +202,7 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
else: else:
site_addr = f"{scheme}://{host}:{port}" site_addr = f"{scheme}://{host}:{port}"
block = ( block = (
CADDYFILE_SITE_BLOCK CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
.replace("SITE_ADDR", site_addr)
.replace("BACKEND_PORT", str(BACKEND_PORT)) .replace("BACKEND_PORT", str(BACKEND_PORT))
.replace("VITE_PORT", str(vite_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 # Read stderr line by line until Caddy signals it's ready or exits
# Caddy outputs logs; "serving initial configuration" means it's ready # Caddy outputs logs; "serving initial configuration" means it's ready
ready = False
while True: while True:
exit_code = caddy_process.poll() exit_code = caddy_process.poll()
if exit_code is not None: if exit_code is not None:
# Process exited - read remaining stderr and report failure # 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: if remaining:
for line in remaining.splitlines(): for line in remaining.splitlines():
if line: if line:
@@ -303,7 +302,6 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
# Check for ready signal # Check for ready signal
if "serving initial configuration" in line: if "serving initial configuration" in line:
ready = True
break break
parsed = parse_caddy_log(line) parsed = parse_caddy_log(line)
@@ -367,7 +365,9 @@ def main():
parser.add_argument("hostport", nargs="?", default=None) parser.add_argument("hostport", nargs="?", default=None)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") 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("--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") parser.add_argument("--auth-host", help="Dedicated auth host")
args, remaining = parser.parse_known_args() args, remaining = parser.parse_known_args()