Moved remote-link links to same location with reset links, avoiding vite routing issue. Realtime lookup of session when three words have been entered.

This commit is contained in:
Leo Vasanko
2025-12-07 00:15:56 +00:00
parent 269aefa9c8
commit dd49907c8d
7 changed files with 280 additions and 53 deletions
+7 -4
View File
@@ -20,16 +20,19 @@ import { computed, onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue' import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
// Check if this is a remote auth URL: /remote/{token} or /auth/remote/{token} // Check if this is a remote auth URL: /{token} or /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null) const remoteAuthToken = ref(null)
function extractRemoteToken() { function extractRemoteToken() {
const path = window.location.pathname const path = window.location.pathname
const match = path.match(/\/(?:auth\/)?remote\/([^/]+)$/) // Match /{token} or /auth/{token} where token is a passphrase with dots
const match = path.match(/\/(?:auth\/)?([^/]+)$/)
if (match) { if (match) {
const token = match[1] const token = match[1]
// Validate it looks like a passphrase (contains dots) // Validate it looks like a passphrase (contains dots, likely 5 words)
if (token.includes('.')) { const parts = token.split('.')
if (parts.length === 5 && parts.every(p => p.length > 0)) {
return token return token
} }
} }
+147 -12
View File
@@ -12,21 +12,44 @@
v-model="code" v-model="code"
type="text" type="text"
:placeholder="placeholder" :placeholder="placeholder"
:disabled="loading" :disabled="loading || completed"
autocomplete="off" autocomplete="off"
autocapitalize="characters" autocapitalize="characters"
spellcheck="false" spellcheck="false"
class="pairing-input" class="pairing-input"
@input="handleInput" @input="handleInput"
/> />
</div>
<!-- Looking up state -->
<div v-if="lookingUp" class="lookup-status">
<span class="lookup-spinner"></span>
<span>Looking up device</span>
</div>
<!-- Device info display (shown when 3 words match a request) -->
<div v-if="deviceInfo && !error && !completed" class="device-info">
<p class="device-info-label">📱 Device requesting login:</p>
<div class="device-info-details">
<div class="device-detail">
<span class="detail-label">Site:</span>
<span class="detail-value">{{ deviceInfo.host }}</span>
</div>
<div class="device-detail">
<span class="detail-label">Browser:</span>
<span class="detail-value">{{ deviceInfo.user_agent_pretty }}</span>
</div>
</div>
<button <button
type="submit" type="submit"
:disabled="!isValid || loading" :disabled="loading"
class="btn-primary" class="btn-primary"
style="margin-top: 0.75rem; width: 100%;"
> >
{{ loading ? 'Connecting…' : 'Connect' }} {{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
</button> </button>
</div> </div>
<p v-if="error" class="error-message">{{ error }}</p> <p v-if="error" class="error-message">{{ error }}</p>
</form> </form>
@@ -38,15 +61,16 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser' import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api'
const props = defineProps({ 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 code' } placeholder: { type: String, default: 'Enter three words' }
}) })
const emit = defineEmits(['completed', 'error', 'cancelled']) const emit = defineEmits(['completed', 'error', 'cancelled'])
@@ -54,25 +78,78 @@ const emit = defineEmits(['completed', 'error', 'cancelled'])
const inputRef = ref(null) const inputRef = ref(null)
const code = ref('') const code = ref('')
const loading = ref(false) const loading = ref(false)
const lookingUp = ref(false)
const error = ref(null) const error = ref(null)
const completed = ref(false) const completed = ref(false)
const completedMessage = ref('') const completedMessage = ref('')
const deviceInfo = ref(null)
let ws = null let ws = null
let lookupTimeout = null
// Valid if we have 3 words separated by dots or spaces // Check if we have exactly 3 valid words
const isValid = computed(() => { const hasThreeWords = computed(() => {
const trimmed = code.value.trim() const trimmed = code.value.trim()
if (!trimmed) return false if (!trimmed) return false
const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0) const words = trimmed.split(/[.\s]+/).filter(w => w.length > 0)
return words.length >= 3 return words.length === 3
}) })
function handleInput() { // Normalize code to dot-separated lowercase
function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
// Watch for code changes and do real-time lookup
watch(code, () => {
// Clear previous timeout
if (lookupTimeout) {
clearTimeout(lookupTimeout)
lookupTimeout = null
}
// Reset device info and error when code changes
deviceInfo.value = null
error.value = null error.value = null
// Only lookup when we have 3 words
if (hasThreeWords.value) {
// Debounce the lookup slightly to avoid too many requests
lookupTimeout = setTimeout(() => {
lookupDeviceInfo()
}, 150)
}
})
async function lookupDeviceInfo() {
if (!hasThreeWords.value || loading.value) return
lookingUp.value = true
error.value = null
try {
const normalizedCode = normalizeCode(code.value)
const info = await apiJson(`/auth/api/remote-auth-info?code=${encodeURIComponent(normalizedCode)}`)
deviceInfo.value = info
} catch (err) {
// 404 means no matching request found
if (err.status === 404) {
error.value = 'No device found with this code. Check the code and try again.'
} else {
console.error('Lookup error:', err)
error.value = err.message || 'Failed to look up device'
}
deviceInfo.value = null
} finally {
lookingUp.value = false
}
}
function handleInput() {
// Error is cleared by the watcher
} }
async function submitCode() { async function submitCode() {
if (!isValid.value || loading.value) return if (!deviceInfo.value || loading.value) return
loading.value = true loading.value = true
error.value = null error.value = null
@@ -81,8 +158,7 @@ async function submitCode() {
const settings = await getSettings() const settings = await getSettings()
const authHost = settings?.auth_host const authHost = settings?.auth_host
// Normalize the code: lowercase words joined by dots const normalizedCode = normalizeCode(code.value)
const normalizedCode = code.value.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}` const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
@@ -131,6 +207,8 @@ function reset() {
error.value = null error.value = null
completed.value = false completed.value = false
completedMessage.value = '' completedMessage.value = ''
deviceInfo.value = null
lookingUp.value = false
} }
onMounted(() => { onMounted(() => {
@@ -196,6 +274,63 @@ defineExpose({ reset })
opacity: 0.6; opacity: 0.6;
} }
.lookup-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.lookup-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.device-info {
padding: 0.875rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
}
.device-info-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.device-info-details {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.device-detail {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
}
.detail-label {
color: var(--color-text-muted);
min-width: 4rem;
}
.detail-value {
color: var(--color-text);
font-weight: 500;
}
.error-message { .error-message {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
+64 -1
View File
@@ -13,6 +13,7 @@ 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,
@@ -24,7 +25,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, userinfo from paskia.util import frontend, hostutil, htmlutil, passphrase, useragent, 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)
@@ -205,6 +206,68 @@ 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.
Returns:
- type: "remote_auth" or "reset"
- For remote_auth: host, user_agent_pretty (requesting device info)
- For reset: user info (display name, etc.)
"""
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)
user = await db.instance.get_user_by_uuid(reset_token.user_uuid)
return {
"type": "reset",
"user_name": user.display_name,
"token_type": reset_token.token_type,
}
except (ValueError, Exception):
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,
+19 -13
View File
@@ -123,19 +123,25 @@ async def examples_page():
# Note: this catch-all handler must be the last route defined # Note: this catch-all handler must be the last route defined
@app.get("/remote/{token}") @app.get("/{token}")
@app.get("/auth/remote/{token}") @app.get("/auth/{token}")
async def remote_auth_link(token: str): async def token_link(token: str):
"""Serve the restricted app for cross-device login.""" """Serve the appropriate app based on token type.
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.
"""
if not passphrase.is_well_formed(token): if not passphrase.is_well_formed(token):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return Response(*await frontend.read("/auth/restricted/index.html"))
# Check if this is a remote auth token first (they're in-memory, fast lookup)
if remoteauth.instance is not None:
@app.get("/{reset}") request = await remoteauth.instance.get_request(token)
@app.get("/auth/{reset}") if request is not None:
async def reset_link(reset: str): return Response(*await frontend.read("/auth/restricted/index.html"))
"""Serve the reset app directly with an injected reset token."""
if not passphrase.is_well_formed(reset): # Otherwise, serve the reset app (it will validate the token via API)
raise HTTPException(status_code=404)
return Response(*await frontend.read("/int/reset/index.html")) return Response(*await frontend.read("/int/reset/index.html"))
+2 -2
View File
@@ -232,8 +232,8 @@ async def websocket_remote_auth_request(ws: WebSocket):
user_agent=metadata.get("user_agent") or "", user_agent=metadata.get("user_agent") or "",
) )
# Build the URL for the authenticating device # Build the URL for the authenticating device (same endpoint as reset tokens)
url = hostutil.auth_site_base_url() + f"remote/{token}" url = hostutil.auth_site_base_url() + token
# Send the token, pairing code, and URL to the client # Send the token, pairing code, and URL to the client
await ws.send_json( await ws.send_json(
+36 -21
View File
@@ -12,6 +12,9 @@ Alternative flow (initiated from profile/authenticating device):
3. Device B authenticates, Device A receives the session 3. Device B authenticates, Device A receives the session
The requests are stored in-memory with short expiration (5 minutes). The requests are stored in-memory with short expiration (5 minutes).
The link uses the same /{token} endpoint as reset tokens, but the server
distinguishes between them by checking if the token exists in remoteauth first.
The first 3 words of the token serve as the pairing code for manual entry.
""" """
import asyncio import asyncio
@@ -26,7 +29,7 @@ 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 (easier to communicate than alphanumeric) # Number of words for the short pairing code (first 3 words of the token)
PAIRING_CODE_WORDS = 3 PAIRING_CODE_WORDS = 3
@@ -34,8 +37,8 @@ PAIRING_CODE_WORDS = 3
class RemoteAuthRequest: class RemoteAuthRequest:
"""A pending remote authentication request.""" """A pending remote authentication request."""
key: str # The passphrase token key: str # The passphrase token (5 words)
pairing_code: str # Short alphanumeric code for manual entry 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
@@ -51,7 +54,10 @@ class RemoteAuthRequest:
def _generate_pairing_code() -> str: def _generate_pairing_code() -> str:
"""Generate a short, easy-to-communicate pairing code using words.""" """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) return passphrase.generate(n=PAIRING_CODE_WORDS)
@@ -60,7 +66,7 @@ class RemoteAuthManager:
def __init__(self): def __init__(self):
self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token
self._by_pairing_code: dict[str, str] = {} # pairing_code -> token 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()
@@ -116,28 +122,37 @@ class RemoteAuthManager:
) -> tuple[str, str, datetime]: ) -> tuple[str, 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.
We ensure the pairing code (first 3 words) is unique across concurrent requests
by regenerating the token if there's a collision.
Returns: Returns:
(token, pairing_code, expiry) - The passphrase token, short pairing code, and expiration time (token, pairing_code, expiry) - The passphrase token, pairing code (first 3 words), and expiration time
""" """
token = passphrase.generate()
pairing_code = _generate_pairing_code()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
expiry = now + REMOTE_AUTH_LIFETIME expiry = now + REMOTE_AUTH_LIFETIME
request = RemoteAuthRequest(
key=token,
pairing_code=pairing_code,
created_at=now,
host=host,
ip=ip,
user_agent=user_agent,
)
async with self._lock: async with self._lock:
# Ensure pairing code is unique (regenerate if collision) # Generate token with unique 3-word prefix
while pairing_code in self._by_pairing_code: max_attempts = 100
pairing_code = _generate_pairing_code() for _ in range(max_attempts):
request.pairing_code = pairing_code token = passphrase.generate()
pairing_code = passphrase.prefix(token, n=PAIRING_CODE_WORDS)
if pairing_code not in self._by_pairing_code:
break
else:
# Extremely unlikely but handle gracefully
raise ValueError("Unable to generate unique pairing code")
request = RemoteAuthRequest(
key=token,
pairing_code=pairing_code,
created_at=now,
host=host,
ip=ip,
user_agent=user_agent,
)
self._requests[token] = request self._requests[token] = request
self._by_pairing_code[pairing_code] = token self._by_pairing_code[pairing_code] = token
+5
View File
@@ -17,3 +17,8 @@ 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])