Don't redirect non-auth-host /auth/ to auth site but show basic info on current host, and allow logging out. Adds a new host app for this purpose.
This commit is contained in:
		
							
								
								
									
										6
									
								
								API.md
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								API.md
									
									
									
									
									
								
							| @@ -14,14 +14,14 @@ Two deployment modes: | |||||||
|  |  | ||||||
| 2. Dedicated auth host (`--auth-host auth.example.com`) | 2. Dedicated auth host (`--auth-host auth.example.com`) | ||||||
|    - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.). |    - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.). | ||||||
|    - Other (non‑auth) hosts expose only non‑restricted API endpoints; UI is redirected to the auth host. |    - Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host. | ||||||
|    - Restricted endpoints on non‑auth hosts return `404` instead of redirecting. |    - Restricted endpoints on non‑auth hosts return `404` instead of redirecting. | ||||||
|  |  | ||||||
| ### Path Mapping When Auth Host Enabled | ### Path Mapping When Auth Host Enabled | ||||||
|  |  | ||||||
| | Purpose | On Auth Host | On Other Hosts (incoming) | Action | | | Purpose | On Auth Host | On Other Hosts (incoming) | Action | | ||||||
| |---------|--------------|---------------------------|--------| | |---------|--------------|---------------------------|--------| | ||||||
| | Main UI | `/` | `/auth/` or `/` | Redirect -> auth host `/` (strip leading `/auth` if present) | | | Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) | | ||||||
| | Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) | | | 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`) | | | Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) | | ||||||
| | Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) | | | Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) | | ||||||
| @@ -38,7 +38,7 @@ Notes: | |||||||
|  |  | ||||||
| | Method | Path (multi‑host) | Path (auth host) | Description | | | Method | Path (multi‑host) | Path (auth host) | Description | | ||||||
| |--------|-------------------|------------------|-------------| | |--------|-------------------|------------------|-------------| | ||||||
| | GET | `/auth/` | `/` | Main authentication SPA | | | GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) | | ||||||
| | GET | `/auth/admin/` | `/admin/` | Admin SPA root | | | GET | `/auth/admin/` | `/admin/` | Admin SPA root | | ||||||
| | GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | | | GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | | ||||||
| | GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA | | | GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA | | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								frontend/host/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/host/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>Account Summary</title> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <div id="app"></div> | ||||||
|  |     <script type="module" src="/src/host/main.js"></script> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										135
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | |||||||
|  | <template> | ||||||
|  |   <div class="app-shell"> | ||||||
|  |     <StatusMessage /> | ||||||
|  |     <main class="view-root host-view"> | ||||||
|  |       <div class="view-content"> | ||||||
|  |         <header class="view-header"> | ||||||
|  |           <h1>{{ headingTitle }}</h1> | ||||||
|  |           <p class="view-lede">{{ subheading }}</p> | ||||||
|  |           <p v-if="authSiteUrl" class="view-hint"> | ||||||
|  |             Manage your full profile on | ||||||
|  |             <a :href="authSiteUrl">{{ authSiteHost }}</a> (you may need to login again). | ||||||
|  |           </p> | ||||||
|  |         </header> | ||||||
|  |  | ||||||
|  |         <section class="section-block"> | ||||||
|  |           <div class="section-body"> | ||||||
|  |             <UserBasicInfo | ||||||
|  |               v-if="user" | ||||||
|  |               :name="user.user_name" | ||||||
|  |               :visits="user.visits || 0" | ||||||
|  |               :created-at="user.created_at" | ||||||
|  |               :last-seen="user.last_seen" | ||||||
|  |               :org-display-name="orgDisplayName" | ||||||
|  |               :role-name="roleDisplayName" | ||||||
|  |               :can-edit="false" | ||||||
|  |             /> | ||||||
|  |             <p v-else class="empty-state"> | ||||||
|  |               {{ initializing ? 'Loading your account…' : 'No active session found.' }} | ||||||
|  |             </p> | ||||||
|  |           </div> | ||||||
|  |         </section> | ||||||
|  |  | ||||||
|  |         <section class="section-block"> | ||||||
|  |           <div class="section-body host-actions"> | ||||||
|  |             <div class="button-row"> | ||||||
|  |               <button | ||||||
|  |                 v-if="authSiteUrl" | ||||||
|  |                 type="button" | ||||||
|  |                 class="btn-secondary" | ||||||
|  |                 :disabled="authStore.isLoading" | ||||||
|  |                 @click="goToAuthSite" | ||||||
|  |               > | ||||||
|  |                 Open full profile | ||||||
|  |               </button> | ||||||
|  |               <button | ||||||
|  |                 type="button" | ||||||
|  |                 class="btn-danger" | ||||||
|  |                 :disabled="authStore.isLoading" | ||||||
|  |                 @click="logout" | ||||||
|  |               > | ||||||
|  |                 {{ authStore.isLoading ? 'Signing out…' : 'Logout' }} | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |             <p class="note">Signed in on <strong>{{ currentHost }}</strong>.</p> | ||||||
|  |           </div> | ||||||
|  |         </section> | ||||||
|  |       </div> | ||||||
|  |     </main> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup> | ||||||
|  | import { computed, onMounted, ref } from 'vue' | ||||||
|  | import StatusMessage from '@/components/StatusMessage.vue' | ||||||
|  | import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||||
|  | import { useAuthStore } from '@/stores/auth' | ||||||
|  |  | ||||||
|  | const authStore = useAuthStore() | ||||||
|  | const initializing = ref(true) | ||||||
|  | const currentHost = window.location.host | ||||||
|  |  | ||||||
|  | const user = computed(() => authStore.userInfo?.user || null) | ||||||
|  | const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '') | ||||||
|  | const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '') | ||||||
|  |  | ||||||
|  | const headingTitle = computed(() => { | ||||||
|  |   const service = authStore.settings?.rp_name | ||||||
|  |   return service ? `${service} account` : 'Account overview' | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const subheading = computed(() => { | ||||||
|  |   const service = authStore.settings?.rp_name || 'this service' | ||||||
|  |   return `You\u2019re signed in via ${service} on ${currentHost}.` | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const authSiteHost = computed(() => authStore.settings?.auth_host || '') | ||||||
|  | const authSiteUrl = computed(() => { | ||||||
|  |   const host = authSiteHost.value | ||||||
|  |   if (!host) return '' | ||||||
|  |   let path = authStore.settings?.ui_base_path ?? '/auth/' | ||||||
|  |   if (!path.startsWith('/')) path = `/${path}` | ||||||
|  |   if (!path.endsWith('/')) path = `${path}/` | ||||||
|  |   const protocol = window.location.protocol || 'https:' | ||||||
|  |   return `${protocol}//${host}${path}` | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const goToAuthSite = () => { | ||||||
|  |   if (!authSiteUrl.value) return | ||||||
|  |   window.location.href = authSiteUrl.value | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const logout = async () => { | ||||||
|  |   await authStore.logout() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(async () => { | ||||||
|  |   try { | ||||||
|  |     await authStore.loadSettings() | ||||||
|  |     const service = authStore.settings?.rp_name | ||||||
|  |     if (service) document.title = `${service} · Account summary` | ||||||
|  |     await authStore.loadUserInfo() | ||||||
|  |   } catch (error) { | ||||||
|  |     const message = error instanceof Error ? error.message : 'Unable to load session details' | ||||||
|  |     authStore.showMessage(message, 'error', 4000) | ||||||
|  |   } finally { | ||||||
|  |     initializing.value = false | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .host-view { padding: 3rem 1.5rem 4rem; } | ||||||
|  | .host-actions { display: flex; flex-direction: column; gap: 0.75rem; } | ||||||
|  | .host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; } | ||||||
|  | .host-actions .button-row button { flex: 0 0 auto; } | ||||||
|  | .note { margin: 0; color: var(--color-text-muted); } | ||||||
|  | .link { color: var(--color-accent); text-decoration: none; } | ||||||
|  | .link:hover { text-decoration: underline; } | ||||||
|  | .view-hint { margin-top: 0.5rem; color: var(--color-text-muted); } | ||||||
|  | .empty-state { margin: 0; color: var(--color-text-muted); } | ||||||
|  | @media (max-width: 600px) { | ||||||
|  |   .host-actions .button-row { flex-direction: column; } | ||||||
|  |   .host-actions .button-row button { width: 100%; } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										11
									
								
								frontend/src/host/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/host/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import '@/assets/style.css' | ||||||
|  |  | ||||||
|  | import { createApp } from 'vue' | ||||||
|  | import { createPinia } from 'pinia' | ||||||
|  | import HostApp from './HostApp.vue' | ||||||
|  |  | ||||||
|  | const app = createApp(HostApp) | ||||||
|  |  | ||||||
|  | app.use(createPinia()) | ||||||
|  |  | ||||||
|  | app.mount('#app') | ||||||
| @@ -33,6 +33,8 @@ export default defineConfig(({ command, mode }) => ({ | |||||||
|           // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. |           // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. | ||||||
|           // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. |           // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. | ||||||
|           if (url === '/auth/' || url === '/auth') return '/' |           if (url === '/auth/' || url === '/auth') return '/' | ||||||
|  |           if (url === '/auth/host' || url === '/auth/host/') return '/host/index.html' | ||||||
|  |           if (url === '/host' || url === '/host/') return '/host/index.html' | ||||||
|           if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/' |           if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/' | ||||||
|           if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') |           if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') | ||||||
|           if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' |           if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' | ||||||
| @@ -53,7 +55,8 @@ export default defineConfig(({ command, mode }) => ({ | |||||||
|         index: resolve(__dirname, 'index.html'), |         index: resolve(__dirname, 'index.html'), | ||||||
|         admin: resolve(__dirname, 'admin/index.html'), |         admin: resolve(__dirname, 'admin/index.html'), | ||||||
|         reset: resolve(__dirname, 'reset/index.html'), |         reset: resolve(__dirname, 'reset/index.html'), | ||||||
|         restricted: resolve(__dirname, 'restricted/index.html') |         restricted: resolve(__dirname, 'restricted/index.html'), | ||||||
|  |         host: resolve(__dirname, 'host/index.html') | ||||||
|       }, |       }, | ||||||
|       output: {} |       output: {} | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -8,7 +8,15 @@ from passkey.util import hostutil, passphrase | |||||||
|  |  | ||||||
| def is_ui_path(path: str) -> bool: | def is_ui_path(path: str) -> bool: | ||||||
|     """Check if the path is a UI endpoint.""" |     """Check if the path is a UI endpoint.""" | ||||||
|     ui_paths = {"/", "/admin", "/admin/", "/auth/", "/auth/admin", "/auth/admin/"} |     ui_paths = { | ||||||
|  |         "/", | ||||||
|  |         "/admin", | ||||||
|  |         "/admin/", | ||||||
|  |         "/auth", | ||||||
|  |         "/auth/", | ||||||
|  |         "/auth/admin", | ||||||
|  |         "/auth/admin/", | ||||||
|  |     } | ||||||
|     if path in ui_paths: |     if path in ui_paths: | ||||||
|         return True |         return True | ||||||
|     # Treat reset token pages as UI (dynamic). Accept single-segment tokens. |     # Treat reset token pages as UI (dynamic). Accept single-segment tokens. | ||||||
| @@ -30,6 +38,8 @@ def is_restricted_path(path: str) -> bool: | |||||||
|  |  | ||||||
| def should_redirect_to_auth_host(path: str) -> bool: | def should_redirect_to_auth_host(path: str) -> bool: | ||||||
|     """Determine if the request should be redirected to the auth host.""" |     """Determine if the request should be redirected to the auth host.""" | ||||||
|  |     if path in {"/", "/auth", "/auth/"}: | ||||||
|  |         return False | ||||||
|     return is_ui_path(path) or is_restricted_path(path) |     return is_ui_path(path) or is_restricted_path(path) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -47,7 +57,7 @@ def should_redirect_auth_path_to_root(path: str) -> bool: | |||||||
|     """Check if /auth/ UI path should be redirected to root on auth host.""" |     """Check if /auth/ UI path should be redirected to root on auth host.""" | ||||||
|     if not path.startswith("/auth/"): |     if not path.startswith("/auth/"): | ||||||
|         return False |         return False | ||||||
|     ui_paths = {"/auth/", "/auth/admin", "/auth/admin/"} |     ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"} | ||||||
|     if path in ui_paths: |     if path in ui_paths: | ||||||
|         return True |         return True | ||||||
|     # Check for reset token |     # Check for reset token | ||||||
|   | |||||||
| @@ -76,6 +76,12 @@ async def frontapp( | |||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         await get_session(auth, host=request.headers.get("host")) |         await get_session(auth, host=request.headers.get("host")) | ||||||
|  |         cfg_host = hostutil.configured_auth_host() | ||||||
|  |         if cfg_host: | ||||||
|  |             cur_host = hostutil.normalize_host(request.headers.get("host")) | ||||||
|  |             cfg_normalized = hostutil.normalize_host(cfg_host) | ||||||
|  |             if cur_host and cfg_normalized and cur_host != cfg_normalized: | ||||||
|  |                 return FileResponse(frontend.file("host", "index.html")) | ||||||
|         return FileResponse(frontend.file("index.html")) |         return FileResponse(frontend.file("index.html")) | ||||||
|     except Exception: |     except Exception: | ||||||
|         if auth: |         if auth: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko