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:
@@ -20,16 +20,19 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import RestrictedAuth from '@/components/RestrictedAuth.vue'
|
||||
import RemoteAuthComplete from '@/components/RemoteAuthComplete.vue'
|
||||
|
||||
// Check if this is a remote auth URL: /remote/{token} or /auth/remote/{token}
|
||||
// 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)
|
||||
|
||||
function extractRemoteToken() {
|
||||
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) {
|
||||
const token = match[1]
|
||||
// Validate it looks like a passphrase (contains dots)
|
||||
if (token.includes('.')) {
|
||||
// Validate it looks like a passphrase (contains dots, likely 5 words)
|
||||
const parts = token.split('.')
|
||||
if (parts.length === 5 && parts.every(p => p.length > 0)) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,44 @@
|
||||
v-model="code"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:disabled="loading"
|
||||
:disabled="loading || completed"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
class="pairing-input"
|
||||
@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
|
||||
type="submit"
|
||||
:disabled="!isValid || loading"
|
||||
:disabled="loading"
|
||||
class="btn-primary"
|
||||
style="margin-top: 0.75rem; width: 100%;"
|
||||
>
|
||||
{{ loading ? 'Connecting…' : 'Connect' }}
|
||||
{{ loading ? 'Authenticating…' : 'Authenticate to Log In Device' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
@@ -38,15 +61,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import aWebSocket from '@/utils/awaitable-websocket'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Help Another Device Sign In' },
|
||||
description: { type: String, default: 'Enter the code shown on the device that needs to sign in.' },
|
||||
placeholder: { type: String, default: 'Enter code' }
|
||||
placeholder: { type: String, default: 'Enter three words' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['completed', 'error', 'cancelled'])
|
||||
@@ -54,25 +78,78 @@ const emit = defineEmits(['completed', 'error', 'cancelled'])
|
||||
const inputRef = ref(null)
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
const lookingUp = ref(false)
|
||||
const error = ref(null)
|
||||
const completed = ref(false)
|
||||
const completedMessage = ref('')
|
||||
const deviceInfo = ref(null)
|
||||
let ws = null
|
||||
let lookupTimeout = null
|
||||
|
||||
// Valid if we have 3 words separated by dots or spaces
|
||||
const isValid = computed(() => {
|
||||
// Check if we have exactly 3 valid words
|
||||
const hasThreeWords = computed(() => {
|
||||
const trimmed = code.value.trim()
|
||||
if (!trimmed) return false
|
||||
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
|
||||
|
||||
// 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() {
|
||||
if (!isValid.value || loading.value) return
|
||||
if (!deviceInfo.value || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -81,8 +158,7 @@ async function submitCode() {
|
||||
const settings = await getSettings()
|
||||
const authHost = settings?.auth_host
|
||||
|
||||
// Normalize the code: lowercase words joined by dots
|
||||
const normalizedCode = code.value.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
|
||||
const normalizedCode = normalizeCode(code.value)
|
||||
|
||||
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
|
||||
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
|
||||
@@ -131,6 +207,8 @@ function reset() {
|
||||
error.value = null
|
||||
completed.value = false
|
||||
completedMessage.value = ''
|
||||
deviceInfo.value = null
|
||||
lookingUp.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -196,6 +274,63 @@ defineExpose({ reset })
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
+64
-1
@@ -13,6 +13,7 @@ from fastapi import (
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import remoteauth
|
||||
from paskia.authsession import (
|
||||
EXPIRES,
|
||||
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.globals import db
|
||||
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
|
||||
|
||||
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")
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
|
||||
+19
-13
@@ -123,19 +123,25 @@ async def examples_page():
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/remote/{token}")
|
||||
@app.get("/auth/remote/{token}")
|
||||
async def remote_auth_link(token: str):
|
||||
"""Serve the restricted app for cross-device login."""
|
||||
@app.get("/{token}")
|
||||
@app.get("/auth/{token}")
|
||||
async def token_link(token: str):
|
||||
"""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):
|
||||
raise HTTPException(status_code=404)
|
||||
return Response(*await frontend.read("/auth/restricted/index.html"))
|
||||
|
||||
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the reset app directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
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"))
|
||||
|
||||
@@ -232,8 +232,8 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
)
|
||||
|
||||
# Build the URL for the authenticating device
|
||||
url = hostutil.auth_site_base_url() + f"remote/{token}"
|
||||
# Build the URL for the authenticating device (same endpoint as reset tokens)
|
||||
url = hostutil.auth_site_base_url() + token
|
||||
|
||||
# Send the token, pairing code, and URL to the client
|
||||
await ws.send_json(
|
||||
|
||||
+36
-21
@@ -12,6 +12,9 @@ Alternative flow (initiated from profile/authenticating device):
|
||||
3. Device B authenticates, Device A receives the session
|
||||
|
||||
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
|
||||
@@ -26,7 +29,7 @@ from paskia.util import passphrase
|
||||
# Remote auth requests expire after this duration
|
||||
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||
|
||||
# Number of words for the short pairing code (easier to communicate than alphanumeric)
|
||||
# Number of words for the short pairing code (first 3 words of the token)
|
||||
PAIRING_CODE_WORDS = 3
|
||||
|
||||
|
||||
@@ -34,8 +37,8 @@ PAIRING_CODE_WORDS = 3
|
||||
class RemoteAuthRequest:
|
||||
"""A pending remote authentication request."""
|
||||
|
||||
key: str # The passphrase token
|
||||
pairing_code: str # Short alphanumeric code for manual entry
|
||||
key: str # The passphrase token (5 words)
|
||||
pairing_code: str # First 3 words of the token for manual entry
|
||||
created_at: datetime
|
||||
host: str # The host where the session should be created
|
||||
ip: str # IP of the requesting device
|
||||
@@ -51,7 +54,10 @@ class RemoteAuthRequest:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -60,7 +66,7 @@ class RemoteAuthManager:
|
||||
|
||||
def __init__(self):
|
||||
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._lock = asyncio.Lock()
|
||||
|
||||
@@ -116,28 +122,37 @@ class RemoteAuthManager:
|
||||
) -> tuple[str, 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.
|
||||
|
||||
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)
|
||||
expiry = now + REMOTE_AUTH_LIFETIME
|
||||
|
||||
request = RemoteAuthRequest(
|
||||
key=token,
|
||||
pairing_code=pairing_code,
|
||||
created_at=now,
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
# Ensure pairing code is unique (regenerate if collision)
|
||||
while pairing_code in self._by_pairing_code:
|
||||
pairing_code = _generate_pairing_code()
|
||||
request.pairing_code = pairing_code
|
||||
# Generate token with unique 3-word prefix
|
||||
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:
|
||||
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._by_pairing_code[pairing_code] = token
|
||||
|
||||
|
||||
@@ -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."""
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user