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:
		
							
								
								
									
										121
									
								
								API.md
									
									
									
									
									
								
							
							
						
						
									
										121
									
								
								API.md
									
									
									
									
									
								
							| @@ -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. Multi‑host (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 (non‑auth) hosts expose only non‑restricted API endpoints; UI is redirected to the auth host. | ||||||
|  |    - Restricted endpoints on non‑auth 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 non‑auth hosts | | ||||||
|  | | WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth 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 (multi‑host) | 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 non‑auth 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 non‑auth hosts when auth host configured | | ||||||
|  | | `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured | | ||||||
|  |  | ||||||
|  | ## Redirection & Status Codes | ||||||
|  |  | ||||||
|  | | Scenario | Response | | ||||||
|  | |----------|----------| | ||||||
|  | | UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped | | ||||||
|  | | Reset token UI path on non‑auth host | 307 redirect (token preserved) | | ||||||
|  | | Restricted API on non‑auth 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. | ||||||
|   | |||||||
| @@ -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> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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> |  | ||||||
| @@ -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() | ||||||
|   | |||||||
| @@ -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) | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
| @@ -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() | ||||||
|   | |||||||
| @@ -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 }) | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal 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 | ||||||
|  | } | ||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 -------------------- | ||||||
|   | |||||||
| @@ -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( | ||||||
|   | |||||||
| @@ -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}") | ||||||
|   | |||||||
| @@ -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", | ||||||
|  |     ) | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko