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
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
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!
Two deployment modes:
### 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
WS /auth/ws/add_credential - Add new credential for existing user
WS /auth/ws/authenticate - Authenticate user with passkey
2. Dedicated auth host (`--auth-host auth.example.com`)
- The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.).
- 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">
<StatusMessage />
<main class="app-main">
<template v-if="initialized">
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
</template>
<ProfileView v-if="initialized" />
<div v-else class="loading-container">
<div class="loading-spinner"></div>
<p>Loading...</p>
@@ -17,27 +14,17 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getSettings } from '@/utils/settings'
import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue'
const store = useAuthStore()
const initialized = ref(false)
onMounted(async () => {
await store.loadSettings()
const message = location.hash.substring(1)
if (message) {
store.showMessage(decodeURIComponent(message), 'error')
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()
}
const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name
try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ }
initialized.value = true
})
</script>

View File

@@ -10,6 +10,7 @@ import AdminOrgDetail from './AdminOrgDetail.vue'
import AdminUserDetail from './AdminUserDetail.vue'
import AdminDialogs from './AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
const info = ref(null)
const loading = ref(true)
@@ -289,10 +290,8 @@ function deletePermission(p) {
onMounted(async () => {
window.addEventListener('hashchange', parseHash)
await authStore.loadSettings()
if (authStore.settings?.rp_name) {
document.title = authStore.settings.rp_name + ' Admin'
}
const settings = await getSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
load()
})
@@ -324,14 +323,14 @@ const selectedUser = computed(() => {
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
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.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: authStore.uiHref() },
{ label: 'Admin', href: authStore.adminHomeHref() }
{ label: 'Auth', href: makeUiHref() },
{ label: 'Admin', href: adminUiPath() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
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 RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
const authStore = useAuthStore()
@@ -147,7 +148,7 @@ const terminateSession = async (session) => {
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
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 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 name = newName.value.trim()

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ export const useAuthStore = defineStore('auth', {
state: () => ({
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info}
settings: null, // Server provided settings (/auth/settings)
isLoading: false,
// UI State
@@ -17,15 +16,6 @@ export const useAuthStore = defineStore('auth', {
},
}),
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: {
setLoading(flag) {
@@ -43,15 +33,6 @@ export const useAuthStore = defineStore('auth', {
}, 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) {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
@@ -113,19 +94,6 @@ export const useAuthStore = defineStore('auth', {
this.userInfo = 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) {
const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'})
const result = await response.json()

View File

@@ -1,13 +1,21 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
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) {
let params = []
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
const qs = params.length ? `?${params.join('&')}` : ''
const url = `/auth/ws/register${qs}`
const ws = await aWebSocket(url)
const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`))
try {
const optionsJSON = await ws.receive_json()
const registrationResponse = await startRegistration({ optionsJSON })
@@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) {
}
export async function authenticate() {
const ws = await aWebSocket('/auth/ws/authenticate')
const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate'))
try {
const optionsJSON = await ws.receive_json()
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)
if not normalized_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)
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")
return session

View File

@@ -35,6 +35,10 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/")
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:
await authz.verify(
auth,
@@ -44,7 +48,9 @@ async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
)
return FileResponse(frontend.file("admin/index.html"))
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 --------------------

View File

@@ -38,6 +38,17 @@ bearer_auth = HTTPBearer(auto_error=True)
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*.
# Consumption is derived from (now + EXPIRES) - current_expires.
# 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
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
if auth:
current_expiry = session_expiry(ctx.session)
@@ -83,7 +98,7 @@ async def validate_token(
session.set_session_cookie(response, auth)
renewed = True
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")
return {
"valid": True,
@@ -95,6 +110,7 @@ async def validate_token(
@app.get("/forward")
async def forward_authentication(
request: Request,
response: Response,
perm: list[str] = Query([]),
auth=Cookie(None, alias="__Host-auth"),
):
@@ -135,8 +151,13 @@ async def forward_authentication(
}
return Response(status_code=204, headers=remote_headers)
except HTTPException as e:
# Let global handler clear cookie; still return HTML surface instead of JSON
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")
@@ -153,7 +174,10 @@ async def get_settings():
@app.post("/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
session_record = None
@@ -339,11 +363,17 @@ async def api_user_info(
@app.put("/user/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:
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()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -362,7 +392,6 @@ async def api_logout(
try:
await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Already logged out"}
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
@@ -379,10 +408,9 @@ async def api_logout_all(
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired")
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"}
@@ -398,7 +426,6 @@ async def api_delete_session(
try:
current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired") from exc
try:
@@ -415,7 +442,7 @@ async def api_delete_session(
await db.instance.delete_session(target_key)
current_terminated = target_key == session_key(auth)
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}
@@ -433,15 +460,28 @@ async def api_set_session(
@app.delete("/user/credential/{uuid}")
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"}
@app.post("/user/create-link")
async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
s = await get_session(auth, host=request.headers.get("host"))
async def api_create_link(
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()
expiry = expires()
await db.instance.create_reset_token(

View File

@@ -2,7 +2,7 @@ import logging
import os
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.staticfiles import StaticFiles
@@ -46,6 +46,40 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
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/api/", api.app)
app.mount("/auth/ws/", ws.app)
@@ -59,8 +93,26 @@ app.mount(
@app.get("/")
@app.get("/auth/")
async def frontapp():
return FileResponse(frontend.file("index.html"))
async def frontapp(
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)
@@ -71,7 +123,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False)
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}")

View File

@@ -35,3 +35,17 @@ def set_session_cookie(response: Response, token: str) -> None:
path="/",
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:
"""Normalize a Host header or hostname by stripping port and lowercasing."""
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
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}")
host = parsed.hostname or parsed.path or ""
host = host.strip("[]") # Remove IPv6 brackets if present
return host.lower() if host else None
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
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