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:
Leo Vasanko
2025-10-04 17:55:08 -06:00
parent f9f4d59c6b
commit 94efb00e34
7 changed files with 183 additions and 6 deletions

6
API.md
View File

@@ -14,14 +14,14 @@ Two deployment modes:
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.
- Other (nonauth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect 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) |
| Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) |
| 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) |
@@ -38,7 +38,7 @@ Notes:
| Method | Path (multihost) | 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/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) |
| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA |

12
frontend/host/index.html Normal file
View 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>

View 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
View 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')

View File

@@ -33,6 +33,8 @@ export default defineConfig(({ command, mode }) => ({
// 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.
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.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
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'),
admin: resolve(__dirname, 'admin/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: {}
}

View File

@@ -8,7 +8,15 @@ from passkey.util import hostutil, passphrase
def is_ui_path(path: str) -> bool:
"""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:
return True
# 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:
"""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)
@@ -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."""
if not path.startswith("/auth/"):
return False
ui_paths = {"/auth/", "/auth/admin", "/auth/admin/"}
ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"}
if path in ui_paths:
return True
# Check for reset token

View File

@@ -76,6 +76,12 @@ async def frontapp(
try:
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"))
except Exception:
if auth: