A major refactoring for more consistent and stricter flows.

- Force using the dedicated authentication site configured via auth-host
- Stricter host validation
- Using the restricted app consistently for all access control (instead of the old loginview).
This commit is contained in:
Leo Vasanko
2025-10-04 15:55:11 -06:00
parent 389e05730b
commit bfb11cc20f
16 changed files with 366 additions and 272 deletions

121
API.md
View File

@@ -1,29 +1,104 @@
# PassKey Auth API Documentation # PassKey Auth API Documentation
This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required"). This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth
service and how they behave depending on whether a dedicated authentication host
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
### HTTP Endpoints ## Base Paths & Host Modes
GET /auth/ - Main authentication app Two deployment modes:
GET /auth/admin/ - Admin app for managing organisations, users and permissions
GET /auth/{reset_token} - Process password reset/share token
POST /auth/api/user-info - Get authenticated user information
POST /auth/api/logout - Logout and delete session
POST /auth/api/set-session - Set session cookie from Authorization header
POST /auth/api/create-link - Create device addition link
DELETE /auth/api/credential/{uuid} - Delete specific credential
DELETE /auth/api/session/{session_id} - Terminate an active session
POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly)
GET /auth/api/forward - Authentication validation for Caddy/Nginx
- On success returns `204 No Content` with [user info](Headers.md)
- Otherwise returns
* `401 Unauthorized` - authentication required
* `403 Forbidden` - missing required permissions
* Serves the authentication app for a login or permission denied page
- Does not renew session!
### WebAuthn/Passkey endpoints (WebSockets) 1. Multihost (default no `--auth-host` provided)
- All endpoints are reachable on any host under the `/auth/` prefix.
- A convenience root (`/`) also serves the main app.
WS /auth/ws/register - Register new user with passkey 2. Dedicated auth host (`--auth-host auth.example.com`)
WS /auth/ws/add_credential - Add new credential for existing user - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
WS /auth/ws/authenticate - Authenticate user with passkey - Other (nonauth) hosts expose only nonrestricted API endpoints; UI is redirected to the auth host.
- Restricted endpoints on nonauth hosts return `404` instead of redirecting.
### Path Mapping When Auth Host Enabled
| Purpose | On Auth Host | On Other Hosts (incoming) | Action |
|---------|--------------|---------------------------|--------|
| Main UI | `/` | `/auth/` or `/` | Redirect -> auth host `/` (strip leading `/auth` if present) |
| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) |
| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) |
| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) |
| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly |
| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on nonauth hosts |
| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on nonauth hosts |
Notes:
- “Strip `/auth`” means only when the path starts with that exact segment.
- A reset token is a single path segment validated by server logic; malformed tokens 404.
- Method and body are preserved for UI redirects (307 Temporary Redirect).
## HTTP UI Endpoints
| Method | Path (multihost) | Path (auth host) | Description |
|--------|-------------------|------------------|-------------|
| GET | `/auth/` | `/` | Main authentication SPA |
| GET | `/auth/admin/` | `/admin/` | Admin SPA root |
| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |
## Core API (Unrestricted available on all hosts)
Always under `/auth/api/` (even on auth host):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/api/validate` | Validate & (conditionally) renew session |
| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) |
| POST | `/auth/api/set-session` | Set cookie from Bearer token |
| POST | `/auth/api/logout` | Logout current session |
| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) |
| POST | `/auth/api/create-link` | Create a device addition link (reset token) |
| DELETE | `/auth/api/credential/{uuid}` | Delete user credential |
| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session |
| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user |
| PUT | `/auth/api/user/display-name` | Update display name |
## Restricted API Namespaces
When `--auth-host` is set, requests to these paths on nonauth hosts return 404:
| Namespace | Examples |
|-----------|----------|
| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... |
| `/auth/api/user` | Segment prefix includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) |
| `/auth/api/ws` | (Reserved / future) |
## WebSockets (Passkey)
| Path | Description | Host Mode Behavior |
|------|-------------|--------------------|
| `/auth/ws/register` | Register new credential (new or existing user) | 404 on nonauth hosts when auth host configured |
| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on nonauth hosts when auth host configured |
## Redirection & Status Codes
| Scenario | Response |
|----------|----------|
| UI path on nonauth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped |
| Reset token UI path on nonauth host | 307 redirect (token preserved) |
| Restricted API on nonauth host | 404 |
| Unrestricted API on any host | Normal response |
| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) |
## Headers for /auth/api/forward
See `Headers.md` for details of headers returned on success (204).
## Notes for Integrators
1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced).
2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host.
3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site.
## Environment & CLI Summary
| Option | Effect |
|--------|--------|
| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere |
---
This document reflects current behavior of the middleware-based host routing logic.

View File

@@ -2,10 +2,7 @@
<div class="app-shell"> <div class="app-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<template v-if="initialized"> <ProfileView v-if="initialized" />
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
</template>
<div v-else class="loading-container"> <div v-else class="loading-container">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<p>Loading...</p> <p>Loading...</p>
@@ -17,27 +14,17 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getSettings } from '@/utils/settings'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
const store = useAuthStore() const store = useAuthStore()
const initialized = ref(false) const initialized = ref(false)
onMounted(async () => { onMounted(async () => {
await store.loadSettings() const settings = await getSettings()
const message = location.hash.substring(1) if (settings?.rp_name) document.title = settings.rp_name
if (message) { try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ }
store.showMessage(decodeURIComponent(message), 'error') initialized.value = true
history.replaceState(null, '', location.pathname)
}
try {
await store.loadUserInfo()
} catch (error) {
console.log('Failed to load user info:', error)
} finally {
initialized.value = true
store.selectView()
}
}) })
</script> </script>

View File

@@ -10,6 +10,7 @@ import AdminOrgDetail from './AdminOrgDetail.vue'
import AdminUserDetail from './AdminUserDetail.vue' import AdminUserDetail from './AdminUserDetail.vue'
import AdminDialogs from './AdminDialogs.vue' import AdminDialogs from './AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
@@ -289,10 +290,8 @@ function deletePermission(p) {
onMounted(async () => { onMounted(async () => {
window.addEventListener('hashchange', parseHash) window.addEventListener('hashchange', parseHash)
await authStore.loadSettings() const settings = await getSettings()
if (authStore.settings?.rp_name) { if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
document.title = authStore.settings.rp_name + ' Admin'
}
load() load()
}) })
@@ -324,14 +323,14 @@ const selectedUser = computed(() => {
const pageHeading = computed(() => { const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User' if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org' if (selectedOrg.value) return 'Admin: Org'
return (authStore.settings?.rp_name || 'Master') + ' Admin' return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
}) })
// Breadcrumb entries for admin app. // Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => { const breadcrumbEntries = computed(() => {
const entries = [ const entries = [
{ label: 'Auth', href: authStore.uiHref() }, { label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: authStore.adminHomeHref() } { label: 'Admin', href: adminUiPath() }
] ]
// Determine organization for user view if selectedOrg not explicitly chosen. // Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null let orgForUser = null

View File

@@ -1,57 +0,0 @@
<template>
<div class="dialog-backdrop">
<div class="dialog-container">
<div class="dialog-content dialog-content--narrow">
<header class="view-header">
<h1>🔐 {{ (authStore.settings?.rp_name || location.origin)}}</h1>
<p class="view-lede">User authentication is required for access.</p>
</header>
<section class="section-block">
<form class="section-body" @submit.prevent="handleLogin">
<button
type="submit"
class="btn-primary"
:disabled="authStore.isLoading"
>
{{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }}
</button>
</form>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const handleLogin = async () => {
try {
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
authStore.currentView = 'profile'
} catch (error) {
authStore.showMessage(error.message, 'error')
}
}
</script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.section-body {
gap: 1.5rem;
}
@media (max-width: 720px) {
button {
width: 100%;
}
}
</style>

View File

@@ -88,6 +88,7 @@ import NameEditForm from '@/components/NameEditForm.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -147,7 +148,7 @@ const terminateSession = async (session) => {
const logoutEverywhere = async () => { await authStore.logoutEverywhere() } const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true } const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: authStore.uiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }); return entries }) const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries })
const saveName = async () => { const saveName = async () => {
const name = newName.value.trim() const name = newName.value.trim()

View File

@@ -63,6 +63,7 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
const status = reactive({ const status = reactive({
show: false, show: false,
@@ -87,11 +88,7 @@ const subtitleMessage = computed(() => {
return `Finish setting up a passkey for ${userInfo.value?.user?.user_name || 'your account'}.` return `Finish setting up a passkey for ${userInfo.value?.user?.user_name || 'your account'}.`
}) })
const uiBasePath = computed(() => { const basePath = computed(() => uiBasePath())
const base = settings.value?.ui_base_path || '/auth/'
if (base === '/') return '/'
return base.endsWith('/') ? base : `${base}/`
})
const canRegister = computed(() => !!(token.value && userInfo.value)) const canRegister = computed(() => !!(token.value && userInfo.value))
@@ -109,13 +106,9 @@ function showMessage(message, type = 'info', duration = 3000) {
async function fetchSettings() { async function fetchSettings() {
try { try {
const res = await fetch('/auth/api/settings') const data = await getSettings()
if (!res.ok) return
const data = await res.json()
settings.value = data settings.value = data
if (data?.rp_name) { if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup`
document.title = `${data.rp_name} · Passkey Setup`
}
} catch (error) { } catch (error) {
console.warn('Unable to load settings', error) console.warn('Unable to load settings', error)
} }

View File

@@ -8,29 +8,22 @@
<main class="view-root"> <main class="view-root">
<div class="view-content"> <div class="view-content">
<div class="surface surface--tight" style="max-width: 520px; margin: 0 auto; width: 100%;"> <div v-if="!initializing" class="surface surface--tight">
<header class="view-header" style="text-align: center;"> <header class="view-header center">
<h1>🚫 Access Restricted</h1> <h1>{{ headingTitle }}</h1>
<p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p>
<p class="view-lede">{{ headerMessage }}</p> <p class="view-lede">{{ headerMessage }}</p>
</header> </header>
<section class="section-block" v-if="initializing"> <section class="section-block">
<div class="section-body center"> <div class="section-body center">
<p>Checking your session</p> <div class="button-row center">
</div> <button class="btn-secondary" :disabled="loading" @click="backNav">Back</button>
</section>
<section class="section-block" v-else>
<div class="section-body center" style="gap: 1.75rem;">
<p>{{ detailText }}</p>
<div class="button-row center" style="justify-content: center;">
<button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser"> <button v-if="canAuthenticate" class="btn-primary" :disabled="loading" @click="authenticateUser">
{{ loading ? 'Signing in' : 'Sign in with Passkey' }} {{ loading ? 'Signing in' : 'Login' }}
</button>
<button class="btn-secondary" :disabled="loading" @click="returnHome">
Go back to Auth Home
</button> </button>
<button v-if="isAuthenticated" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button>
<button v-if="isAuthenticated" class="btn-primary" :disabled="loading" @click="returnHome">Profile</button>
</div> </div>
</div> </div>
</section> </section>
@@ -43,64 +36,44 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
const status = reactive({ const status = reactive({ show: false, message: '', type: 'info' })
show: false,
message: '',
type: 'info'
})
const initializing = ref(true) const initializing = ref(true)
const loading = ref(false) const loading = ref(false)
const settings = ref(null) const settings = ref(null)
const userInfo = ref(null) const userInfo = ref(null)
const fallbackDetail = ref('')
let statusTimer = null let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated) const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value) const canAuthenticate = computed(() => !initializing.value && !isAuthenticated.value)
const uiBasePath = computed(() => { const basePath = computed(() => uiBasePath())
const base = settings.value?.ui_base_path || '/auth/'
if (base === '/') return '/' const headingTitle = computed(() => {
return base.endsWith('/') ? base : `${base}/` if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}`
return '🚫 Forbidden'
}) })
const headerMessage = computed(() => { const headerMessage = computed(() => {
if (initializing.value) return 'Checking your access permissions…' if (!isAuthenticated.value) return 'Please sign in to access this page.'
if (isAuthenticated.value) { return 'You lack the permissions required to access this page.'
return 'Your account is signed in, but this resource needs extra permissions.'
}
return 'Sign in to continue to the requested resource.'
}) })
const detailText = computed(() => { const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
if (isAuthenticated.value) {
return fallbackDetail.value || 'You do not have the required permissions to view this page.'
}
return fallbackDetail.value || 'Use your registered passkey to sign in securely.'
})
function showMessage(message, type = 'info', duration = 3000) { function showMessage(message, type = 'info', duration = 3000) {
status.show = true status.show = true
status.message = message status.message = message
status.type = type status.type = type
if (statusTimer) clearTimeout(statusTimer) if (statusTimer) clearTimeout(statusTimer)
if (duration > 0) { if (duration > 0) statusTimer = setTimeout(() => { status.show = false }, duration)
statusTimer = setTimeout(() => {
status.show = false
}, duration)
}
} }
async function fetchSettings() { async function fetchSettings() {
try { try {
const res = await fetch('/auth/api/settings') const data = await getSettings()
if (!res.ok) return
const data = await res.json()
settings.value = data settings.value = data
if (data?.rp_name) { if (data?.rp_name) document.title = isAuthenticated.value ? `${data.rp_name} · Forbidden` : `${data.rp_name} · Sign In`
document.title = `${data.rp_name} · Access Restricted`
}
} catch (error) { } catch (error) {
console.warn('Unable to load settings', error) console.warn('Unable to load settings', error)
} }
@@ -109,15 +82,18 @@ async function fetchSettings() {
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
const res = await fetch('/auth/api/user-info', { method: 'POST' }) const res = await fetch('/auth/api/user-info', { method: 'POST' })
console.log("fetchUserInfo response:", res); // Debug log
if (!res.ok) { if (!res.ok) {
const payload = await safeParseJson(res) const payload = await safeParseJson(res)
fallbackDetail.value = payload?.detail || 'Please sign in to continue.' showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000)
return return
} }
userInfo.value = await res.json() userInfo.value = await res.json()
// If the user is authenticated but still here, they lack permissions.
if (isAuthenticated.value) showMessage('Permission Denied', 'error', 2000)
} catch (error) { } catch (error) {
console.error('Failed to load user info', error) console.error('Failed to load user info', error)
fallbackDetail.value = 'We were unable to verify your session. Try again shortly.' showMessage('Could not contact the authentication server', 'error', 2000)
} }
} }
@@ -125,83 +101,76 @@ async function authenticateUser() {
if (!canAuthenticate.value || loading.value) return if (!canAuthenticate.value || loading.value) return
loading.value = true loading.value = true
showMessage('Starting authentication…', 'info') showMessage('Starting authentication…', 'info')
let result let result
try { try { result = await passkey.authenticate() } catch (error) {
result = await passkey.authenticate()
} catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Passkey authentication cancelled' const message = error?.message || 'Passkey authentication cancelled'
const cancelled = message === 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled'
showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000) showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return return
} }
try { await setSessionCookie(result.session_token) } catch (error) {
try {
await setSessionCookie(result.session_token)
} catch (error) {
loading.value = false loading.value = false
const message = error?.message || 'Failed to establish session' const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000) showMessage(message, 'error', 4000)
return return
} }
location.reload()
}
showMessage('Signed in successfully!', 'success', 2000) async function logoutUser() {
setTimeout(() => { if (loading.value) return
loading.value = false loading.value = true
window.location.reload() try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ }
}, 800) finally { loading.value = false; window.location.reload() }
} }
async function setSessionCookie(sessionToken) { async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', { const response = await fetch('/auth/api/set-session', {
method: 'POST', method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
headers: {
Authorization: `Bearer ${sessionToken}`
}
}) })
const payload = await safeParseJson(response) const payload = await safeParseJson(response)
if (!response.ok || payload?.detail) { if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.')
const detail = payload?.detail || 'Session could not be established.'
throw new Error(detail)
}
return payload return payload
} }
function returnHome() { function returnHome() {
const target = uiBasePath.value || '/auth/' const target = basePath.value || '/auth/'
if (window.location.pathname !== target) { if (window.location.pathname !== target) history.replaceState(null, '', target)
history.replaceState(null, '', target)
}
window.location.href = target window.location.href = target
} }
async function safeParseJson(response) { function backNav() {
try { try {
return await response.json() if (history.length > 1) {
} catch (error) { history.back()
return null return
} }
} catch (_) { /* ignore */ }
returnHome()
} }
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
onMounted(async () => { onMounted(async () => {
await fetchSettings() await fetchSettings()
await fetchUserInfo() await fetchUserInfo()
if (!canAuthenticate.value && !isAuthenticated.value && !fallbackDetail.value) {
fallbackDetail.value = 'Please try signing in again.'
}
initializing.value = false initializing.value = false
}) })
</script> </script>
<style scoped> <style scoped>
.center { .button-row.center { display: flex; justify-content: center; gap: 0.75rem; }
text-align: center; .user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); }
} /* Vertically center the restricted "dialog" surface in the viewport */
main.view-root { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
.button-row.center { main.view-root .view-content { width: 100%; }
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex; display: flex;
justify-content: center; flex-direction: column;
gap: 0.75rem; gap: 1.75rem;
} }
</style> </style>

View File

@@ -5,7 +5,6 @@ export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
// Auth State // Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
settings: null, // Server provided settings (/auth/settings)
isLoading: false, isLoading: false,
// UI State // UI State
@@ -17,15 +16,6 @@ export const useAuthStore = defineStore('auth', {
}, },
}), }),
getters: { getters: {
uiBasePath(state) {
const configured = state.settings?.ui_base_path || '/auth/'
if (!configured.endsWith('/')) return `${configured}/`
return configured
},
adminUiPath() {
const base = this.uiBasePath
return base === '/' ? '/admin/' : `${base}admin/`
},
}, },
actions: { actions: {
setLoading(flag) { setLoading(flag) {
@@ -43,15 +33,6 @@ export const useAuthStore = defineStore('auth', {
}, duration) }, duration)
} }
}, },
uiHref(suffix = '') {
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
if (!trimmed) return this.uiBasePath
if (this.uiBasePath === '/') return `/${trimmed}`
return `${this.uiBasePath}${trimmed}`
},
adminHomeHref() {
return this.adminUiPath
},
async setSessionCookie(sessionToken) { async setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', { const response = await fetch('/auth/api/set-session', {
method: 'POST', method: 'POST',
@@ -113,19 +94,6 @@ export const useAuthStore = defineStore('auth', {
this.userInfo = result this.userInfo = result
console.log('User info loaded:', result) console.log('User info loaded:', result)
}, },
async loadSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
this.settings = data
if (data?.rp_name) {
document.title = data.rp_name
}
} catch (_) {
// ignore
}
},
async deleteCredential(uuid) { async deleteCredential(uuid) {
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'}) const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'})
const result = await response.json() const result = await response.json()

View File

@@ -1,13 +1,21 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
// Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
async function makeUrl(path) {
const s = await getSettings()
const h = s?.auth_host
return h && location.host !== h ? `//${h}${path}` : path
}
export async function register(resetToken = null, displayName = null) { export async function register(resetToken = null, displayName = null) {
let params = [] let params = []
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
const qs = params.length ? `?${params.join('&')}` : '' const qs = params.length ? `?${params.join('&')}` : ''
const url = `/auth/ws/register${qs}` const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
const ws = await aWebSocket(url)
try { try {
const optionsJSON = await ws.receive_json() const optionsJSON = await ws.receive_json()
const registrationResponse = await startRegistration({ optionsJSON }) const registrationResponse = await startRegistration({ optionsJSON })
@@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) {
} }
export async function authenticate() { export async function authenticate() {
const ws = await aWebSocket('/auth/ws/authenticate') const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
try { try {
const optionsJSON = await ws.receive_json() const optionsJSON = await ws.receive_json()
const authResponse = await startAuthentication({ optionsJSON }) const authResponse = await startAuthentication({ optionsJSON })

View File

@@ -0,0 +1,29 @@
let _settingsPromise = null
let _settings = null
export function getSettingsCached() { return _settings }
export async function getSettings() {
if (_settings) return _settings
if (_settingsPromise) return _settingsPromise
_settingsPromise = fetch('/auth/api/settings')
.then(r => (r.ok ? r.json() : {}))
.then(obj => { _settings = obj || {}; return _settings })
.catch(() => { _settings = {}; return _settings })
return _settingsPromise
}
export function uiBasePath() {
const base = _settings?.ui_base_path || '/auth/'
if (base === '/') return '/'
return base.endsWith('/') ? base : base + '/'
}
export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' }
export function makeUiHref(suffix = '') {
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
if (!trimmed) return uiBasePath()
if (uiBasePath() === '/') return '/' + trimmed
return uiBasePath() + trimmed
}

View File

@@ -85,10 +85,14 @@ async def get_session(token: str, host: str | None = None) -> Session:
normalized_host = hostutil.normalize_host(host) normalized_host = hostutil.normalize_host(host)
if not normalized_host: if not normalized_host:
raise ValueError("Invalid host") raise ValueError("Invalid host")
if session.host is None: current = session.host
if current is None:
# First time binding: store exact host:port (or IPv6 form) now.
await db.instance.set_session_host(session.key, normalized_host) await db.instance.set_session_host(session.key, normalized_host)
session.host = normalized_host session.host = normalized_host
elif session.host != normalized_host: elif current == normalized_host:
pass # exact match ok
else:
raise ValueError("Invalid or expired session token") raise ValueError("Invalid or expired session token")
return session return session

View File

@@ -35,6 +35,10 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/") @app.get("/")
async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")): async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
"""Serve admin SPA only for authenticated users with admin/org permissions.
On missing/invalid session or insufficient permissions, serve restricted SPA.
"""
try: try:
await authz.verify( await authz.verify(
auth, auth,
@@ -44,7 +48,9 @@ async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
) )
return FileResponse(frontend.file("admin/index.html")) return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e: except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code) return FileResponse(
frontend.file("restricted", "index.html"), status_code=e.status_code
)
# -------------------- Organizations -------------------- # -------------------- Organizations --------------------

View File

@@ -38,6 +38,17 @@ bearer_auth = HTTPBearer(auto_error=True)
app = FastAPI() app = FastAPI()
@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException):
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
if exc.status_code == 401:
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
session.clear_session_cookie(resp)
return resp
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Refresh only if at least this much of the session lifetime has been *consumed*. # Refresh only if at least this much of the session lifetime has been *consumed*.
# Consumption is derived from (now + EXPIRES) - current_expires. # Consumption is derived from (now + EXPIRES) - current_expires.
# This guarantees a minimum spacing between DB writes even with frequent /validate calls. # This guarantees a minimum spacing between DB writes even with frequent /validate calls.
@@ -68,7 +79,11 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint. refresh endpoint.
""" """
ctx = await authz.verify(auth, perm, host=request.headers.get("host")) try:
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
except HTTPException:
# Global handler will clear cookie if 401
raise
renewed = False renewed = False
if auth: if auth:
current_expiry = session_expiry(ctx.session) current_expiry = session_expiry(ctx.session)
@@ -83,7 +98,7 @@ async def validate_token(
session.set_session_cookie(response, auth) session.set_session_cookie(response, auth)
renewed = True renewed = True
except ValueError: except ValueError:
# Session disappeared, e.g. due to concurrent logout # Session disappeared, e.g. due to concurrent logout; global handler will clear
raise HTTPException(status_code=401, detail="Session expired") raise HTTPException(status_code=401, detail="Session expired")
return { return {
"valid": True, "valid": True,
@@ -95,6 +110,7 @@ async def validate_token(
@app.get("/forward") @app.get("/forward")
async def forward_authentication( async def forward_authentication(
request: Request, request: Request,
response: Response,
perm: list[str] = Query([]), perm: list[str] = Query([]),
auth=Cookie(None, alias="__Host-auth"), auth=Cookie(None, alias="__Host-auth"),
): ):
@@ -135,8 +151,13 @@ async def forward_authentication(
} }
return Response(status_code=204, headers=remote_headers) return Response(status_code=204, headers=remote_headers)
except HTTPException as e: except HTTPException as e:
# Let global handler clear cookie; still return HTML surface instead of JSON
html = frontend.file("restricted", "index.html").read_bytes() html = frontend.file("restricted", "index.html").read_bytes()
return Response(html, status_code=e.status_code, media_type="text/html") status = e.status_code
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
if status == 401:
session.clear_session_cookie(response)
return Response(html, status_code=status, media_type="text/html")
@app.get("/settings") @app.get("/settings")
@@ -153,7 +174,10 @@ async def get_settings():
@app.post("/user-info") @app.post("/user-info")
async def api_user_info( async def api_user_info(
request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth") request: Request,
response: Response,
reset: str | None = None,
auth=Cookie(None, alias="__Host-auth"),
): ):
authenticated = False authenticated = False
session_record = None session_record = None
@@ -339,11 +363,17 @@ async def api_user_info(
@app.put("/user/display-name") @app.put("/user/display-name")
async def user_update_display_name( async def user_update_display_name(
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth") request: Request,
response: Response,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
): ):
if not auth: if not auth:
raise HTTPException(status_code=401, detail="Authentication Required") raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth, host=request.headers.get("host")) try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
new_name = (payload.get("display_name") or "").strip() new_name = (payload.get("display_name") or "").strip()
if not new_name: if not new_name:
raise HTTPException(status_code=400, detail="display_name required") raise HTTPException(status_code=400, detail="display_name required")
@@ -362,7 +392,6 @@ async def api_logout(
try: try:
await get_session(auth, host=request.headers.get("host")) await get_session(auth, host=request.headers.get("host"))
except ValueError: except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Already logged out"} return {"message": "Already logged out"}
with suppress(Exception): with suppress(Exception):
await db.instance.delete_session(session_key(auth)) await db.instance.delete_session(session_key(auth))
@@ -379,10 +408,9 @@ async def api_logout_all(
try: try:
s = await get_session(auth, host=request.headers.get("host")) s = await get_session(auth, host=request.headers.get("host"))
except ValueError: except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired") raise HTTPException(status_code=401, detail="Session expired")
await db.instance.delete_sessions_for_user(s.user_uuid) await db.instance.delete_sessions_for_user(s.user_uuid)
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"} return {"message": "Logged out from all hosts"}
@@ -398,7 +426,6 @@ async def api_delete_session(
try: try:
current_session = await get_session(auth, host=request.headers.get("host")) current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc: except ValueError as exc:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired") from exc raise HTTPException(status_code=401, detail="Session expired") from exc
try: try:
@@ -415,7 +442,7 @@ async def api_delete_session(
await db.instance.delete_session(target_key) await db.instance.delete_session(target_key)
current_terminated = target_key == session_key(auth) current_terminated = target_key == session_key(auth)
if current_terminated: if current_terminated:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated} return {"status": "ok", "current_session_terminated": current_terminated}
@@ -433,15 +460,28 @@ async def api_set_session(
@app.delete("/user/credential/{uuid}") @app.delete("/user/credential/{uuid}")
async def api_delete_credential( async def api_delete_credential(
request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth") request: Request,
response: Response,
uuid: UUID,
auth: str = Cookie(None, alias="__Host-auth"),
): ):
await delete_credential(uuid, auth, host=request.headers.get("host")) try:
await delete_credential(uuid, auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
return {"message": "Credential deleted successfully"} return {"message": "Credential deleted successfully"}
@app.post("/user/create-link") @app.post("/user/create-link")
async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")): async def api_create_link(
s = await get_session(auth, host=request.headers.get("host")) request: Request,
response: Response,
auth=Cookie(None, alias="__Host-auth"),
):
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
token = passphrase.generate() token = passphrase.generate()
expiry = expires() expiry = expires()
await db.instance.create_reset_token( await db.instance.create_reset_token(

View File

@@ -2,7 +2,7 @@ import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Cookie, FastAPI, HTTPException, Request from fastapi import Cookie, 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
@@ -46,6 +46,40 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def auth_host_redirect(request, call_next): # pragma: no cover
cfg = hostutil.configured_auth_host()
if not cfg:
return await call_next(request)
cur = hostutil.normalize_host(request.headers.get("host"))
if not cur or cur == hostutil.normalize_host(cfg):
return await call_next(request)
p = request.url.path or "/"
ui = {"/", "/admin", "/admin/", "/auth/", "/auth/admin", "/auth/admin/"}
restricted = p.startswith(
("/auth/api/admin", "/auth/api/user", "/auth/api/ws", "/auth/ws/")
)
ui_match = p in ui
if not ui_match:
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
if p.startswith("/auth/"):
t = p[6:]
if t and "/" not in t and passphrase.is_well_formed(t):
ui_match = True
else:
t = p[1:]
if t and "/" not in t and passphrase.is_well_formed(t):
ui_match = True
if not (ui_match or restricted):
return await call_next(request)
if restricted:
return Response(status_code=404)
newp = p[5:] or "/" if ui_match and p.startswith("/auth") else p
return RedirectResponse(f"{request.url.scheme}://{cfg}{newp}", 307)
app.mount("/auth/admin/", admin.app) app.mount("/auth/admin/", admin.app)
app.mount("/auth/api/", api.app) app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app) app.mount("/auth/ws/", ws.app)
@@ -59,8 +93,26 @@ app.mount(
@app.get("/") @app.get("/")
@app.get("/auth/") @app.get("/auth/")
async def frontapp(): async def frontapp(
return FileResponse(frontend.file("index.html")) request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
):
"""Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA.
Login / authentication UX is centralized in the restricted app.
"""
if not auth:
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
from ..authsession import get_session # local import
try:
await get_session(auth, host=request.headers.get("host"))
return FileResponse(frontend.file("index.html"))
except Exception:
if auth:
from . import session as session_mod
session_mod.clear_session_cookie(response)
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
@app.get("/admin", include_in_schema=False) @app.get("/admin", include_in_schema=False)
@@ -71,7 +123,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")): async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/ return await admin.adminapp(request, auth) # Delegated (enforces access control)
@app.get("/{reset}") @app.get("/{reset}")

View File

@@ -35,3 +35,17 @@ def set_session_cookie(response: Response, token: str) -> None:
path="/", path="/",
samesite="lax", samesite="lax",
) )
def clear_session_cookie(response: Response) -> None:
# FastAPI's delete_cookie does not set the secure attribute
response.set_cookie(
key=AUTH_COOKIE_NAME,
value="",
max_age=0,
expires=0,
httponly=True,
secure=True,
path="/",
samesite="lax",
)

View File

@@ -73,14 +73,20 @@ def reload_config() -> None:
def normalize_host(raw_host: str | None) -> str | None: def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header or hostname by stripping port and lowercasing.""" """Normalize a Host header preserving port (exact match required)."""
if not raw_host: if not raw_host:
return None return None
candidate = raw_host.strip() candidate = raw_host.strip()
if not candidate: if not candidate:
return None return None
# Ensure urlsplit can parse bare hosts (prepend //) # urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}") parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
host = parsed.hostname or parsed.path or "" netloc = parsed.netloc or parsed.path or ""
host = host.strip("[]") # Remove IPv6 brackets if present # Strip IPv6 brackets around host part but retain port suffix.
return host.lower() if host else None if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None