Refactor user-profile, restricted access and reset token registration as separate apps so the frontend does not need to guess which context it is running in.

Support user-navigable URLs at / as well as /auth/, allowing for a dedicated authentication site with pretty URLs.
This commit is contained in:
Leo Vasanko
2025-10-02 15:42:01 -06:00
parent fbfd0bbb47
commit 5d8304bbd9
23 changed files with 668 additions and 295 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication</title>
<title>Auth Profile</title>
</head>
<body>
<div id="app"></div>

12
frontend/reset/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>Complete Passkey Setup</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/reset/main.js"></script>
</body>
</html>

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>Access Restricted</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/restricted/main.js"></script>
</body>
</html>

View File

@@ -7,8 +7,6 @@
<LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</template>
<!-- Show loading state while determining auth status -->
<div v-else class="loading-container">
@@ -26,21 +24,10 @@ import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore()
const initialized = ref(false)
onMounted(async () => {
// Detect restricted mode:
// We only allow full functionality on the exact /auth/ (or /auth) path.
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
// so the app will only show login or permission denied views.
const path = location.pathname
if (!(path === '/auth/' || path === '/auth')) {
store.setRestrictedMode(true)
}
// Load branding / settings first (non-blocking for auth flow)
await store.loadSettings()
// Was an error message passed in the URL hash?
@@ -49,23 +36,11 @@ onMounted(async () => {
store.showMessage(decodeURIComponent(message), 'error')
history.replaceState(null, '', location.pathname)
}
// Capture reset token from query parameter and then remove it
const params = new URLSearchParams(location.search)
const reset = params.get('reset')
if (reset) {
store.resetToken = reset
// Remove query param to avoid lingering in history / clipboard
const targetPath = '/auth/'
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
}
try {
await store.loadUserInfo()
initialized.value = true
store.selectView()
} catch (error) {
console.log('Failed to load user info:', error)
store.currentView = 'login'
} finally {
initialized.value = true
store.selectView()
}

View File

@@ -330,8 +330,8 @@ const pageHeading = computed(() => {
// Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'Auth', href: '/auth/' },
{ label: 'Admin', href: '/auth/admin/' }
{ label: 'Auth', href: authStore.uiHref() },
{ label: 'Admin', href: authStore.adminHomeHref() }
]
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null

View File

@@ -162,7 +162,6 @@ a:focus-visible {
.view-header h1 {
margin: 0;
font-size: clamp(1.85rem, 2.5vw + 1rem, 2.6rem);
font-weight: 600;
color: var(--color-heading);
}

View File

@@ -32,13 +32,7 @@ const handleLogin = async () => {
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) {
location.reload()
} else if (location.pathname === '/auth/') {
authStore.currentView = 'profile'
} else {
location.reload()
}
authStore.currentView = 'profile'
} catch (error) {
authStore.showMessage(error.message, 'error')
}

View File

@@ -1,94 +0,0 @@
<template>
<div class="dialog-backdrop">
<div class="dialog-container">
<div class="dialog-content dialog-content--wide">
<header class="view-header">
<h1>🚫 Forbidden</h1>
</header>
<section class="section-block">
<div class="section-body">
<div v-if="authStore.userInfo?.authenticated" class="user-header">
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
<span class="user-name">{{ displayName }}</span>
</div>
<p>You lack the permissions required for this page.</p>
<div class="button-row">
<button class="btn-secondary" @click="back">Back</button>
<button class="btn-primary" @click="goAuth">Account</button>
<button class="btn-danger" @click="logout">Logout</button>
</div>
<p class="hint">If you believe this is an error, contact your administrator.</p>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
const displayName = authStore.userInfo?.user?.user_name || 'User'
function goAuth() {
location.href = '/auth/'
}
function back() {
if (history.length > 1) history.back()
else authStore.currentView = 'login'
}
async function logout() {
await authStore.logout()
}
</script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.user-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
}
.user-emoji {
font-size: 1.5rem;
line-height: 1;
}
.user-name {
font-weight: 600;
color: var(--color-heading);
}
.button-row {
width: 100%;
justify-content: stretch;
}
.button-row button {
flex: 1 1 0;
}
.hint {
font-size: 0.9rem;
color: var(--color-text-muted);
margin: 0;
}
@media (max-width: 720px) {
.button-row {
flex-direction: column;
}
.button-row button {
width: 100%;
flex: 1 1 auto;
}
}
</style>

View File

@@ -3,7 +3,7 @@
<div class="view-content">
<header class="view-header">
<h1>👋 Welcome!</h1>
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
<Breadcrumbs :entries="breadcrumbEntries" />
<p class="view-lede">Manage your account details and passkeys.</p>
</header>
@@ -144,6 +144,12 @@ const openNameDialog = () => {
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 saveName = async () => {
const name = newName.value.trim()
if (!name) {

View File

@@ -1,94 +0,0 @@
<template>
<div class="dialog-backdrop">
<div class="dialog-container">
<div class="dialog-content">
<header class="view-header">
<h1>🔑 Add New Credential</h1>
<p class="view-lede">
Finish setting up your passkey to complete {{ authStore.userInfo?.session_type }}.
</p>
</header>
<section class="section-block">
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="user_name"
:placeholder="authStore.userInfo?.user?.user_name || 'Your name'"
:disabled="authStore.isLoading"
maxlength="64"
@keyup.enter="register"
/>
</label>
<p>Proceed to complete {{ authStore.userInfo?.session_type }}:</p>
<button
class="btn-primary"
:disabled="authStore.isLoading"
@click="register"
>
{{ authStore.isLoading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
import passkey from '@/utils/passkey'
import { ref } from 'vue'
const authStore = useAuthStore()
const user_name = ref('')
async function register() {
authStore.isLoading = true
authStore.showMessage('Starting registration...', 'info')
try {
const result = await passkey.register(authStore.resetToken, user_name.value)
console.log('Result', result)
await authStore.setSessionCookie(result.session_token)
authStore.resetToken = null
authStore.showMessage('Passkey registered successfully!', 'success', 2000)
await authStore.loadUserInfo()
authStore.selectView()
} catch (error) {
authStore.showMessage(`Registration failed: ${error.message}`, 'error')
} finally {
authStore.isLoading = false
}
}
</script>
<style scoped>
.view-lede {
margin: 0;
color: var(--color-text-muted);
}
.name-edit {
display: flex;
flex-direction: column;
gap: 0.45rem;
font-weight: 600;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.section-body {
gap: 1.5rem;
}
@media (max-width: 720px) {
button {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,258 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<main class="view-root">
<div class="view-content">
<div class="surface surface--tight" style="max-width: 560px; margin: 0 auto; width: 100%;">
<header class="view-header" style="text-align: center;">
<h1>🔑 Complete Your Passkey Setup</h1>
<p class="view-lede">
{{ subtitleMessage }}
</p>
</header>
<section class="section-block" v-if="initializing">
<div class="section-body center">
<p>Loading reset details</p>
</div>
</section>
<section class="section-block" v-else-if="!canRegister">
<div class="section-body center">
<p>{{ errorMessage }}</p>
<div class="button-row center" style="justify-content: center;">
<button class="btn-secondary" @click="goHome">Return to sign-in</button>
</div>
</div>
</section>
<section class="section-block" v-else>
<div class="section-body">
<label class="name-edit">
<span>👤 Name</span>
<input
type="text"
v-model="displayName"
:placeholder="namePlaceholder"
:disabled="loading"
maxlength="64"
@keyup.enter="registerPasskey"
/>
</label>
<p>Click below to finish {{ sessionDescriptor }}.</p>
<button
class="btn-primary"
:disabled="loading"
@click="registerPasskey"
>
{{ loading ? 'Registering…' : 'Register Passkey' }}
</button>
</div>
</section>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
const status = reactive({
show: false,
message: '',
type: 'info'
})
const initializing = ref(true)
const loading = ref(false)
const token = ref('')
const settings = ref(null)
const userInfo = ref(null)
const displayName = ref('')
const errorMessage = ref('')
let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const namePlaceholder = computed(() => userInfo.value?.user?.user_name || 'Your name')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This reset link is no longer valid.'
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 canRegister = computed(() => !!(token.value && userInfo.value))
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)
}
}
async function fetchSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
settings.value = data
if (data?.rp_name) {
document.title = `${data.rp_name} · Passkey Setup`
}
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
if (!token.value) return
try {
const res = await fetch(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
method: 'POST'
})
if (!res.ok) {
const payload = await safeParseJson(res)
const detail = payload?.detail || 'Reset link is invalid or expired.'
errorMessage.value = detail
showMessage(detail, 'error', 0)
return
}
userInfo.value = await res.json()
} catch (error) {
console.error('Failed to load user info', error)
const message = 'We could not load your reset details. Try refreshing the page.'
errorMessage.value = message
showMessage(message, 'error', 0)
}
}
async function registerPasskey() {
if (!canRegister.value || loading.value) return
loading.value = true
showMessage('Starting passkey registration…', 'info')
let result
try {
const nameValue = displayName.value.trim() || null
result = await passkey.register(token.value, nameValue)
} catch (error) {
loading.value = false
const message = error?.message || 'Passkey registration cancelled'
const cancelled = message === 'Passkey registration cancelled'
showMessage(cancelled ? message : `Registration failed: ${message}`, cancelled ? 'info' : 'error', 4000)
return
}
try {
await setSessionCookie(result.session_token)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Passkey registered successfully!', 'success', 2000)
setTimeout(() => {
loading.value = false
redirectHome()
}, 800)
}
async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
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)
}
return payload
}
function redirectHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.reload()
}
function goHome() {
redirectHome()
}
function extractTokenFromPath() {
const segments = window.location.pathname.split('/').filter(Boolean)
if (!segments.length) return ''
const candidate = segments[segments.length - 1]
const prefix = segments.slice(0, -1)
if (prefix.length > 1) return ''
if (prefix.length === 1 && prefix[0] !== 'auth') return ''
if (!candidate.includes('.')) return ''
return candidate
}
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
return null
}
}
onMounted(async () => {
token.value = extractTokenFromPath()
await fetchSettings()
if (!token.value) {
const message = 'Reset link is missing or malformed.'
errorMessage.value = message
showMessage(message, 'error', 0)
initializing.value = false
return
}
await fetchUserInfo()
initializing.value = false
})
</script>
<style scoped>
.center {
text-align: center;
}
.button-row.center {
display: flex;
justify-content: center;
}
.section-body {
gap: 1.25rem;
}
.name-edit span {
color: var(--color-text-muted);
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import ResetApp from './ResetApp.vue'
import '@/assets/style.css'
createApp(ResetApp).mount('#app')

View File

@@ -0,0 +1,207 @@
<template>
<div class="app-shell">
<div v-if="status.show" class="global-status" style="display: block;">
<div :class="['status', status.type]">
{{ status.message }}
</div>
</div>
<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>
<p class="view-lede">{{ headerMessage }}</p>
</header>
<section class="section-block" v-if="initializing">
<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;">
<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
</button>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
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 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.'
})
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.'
})
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)
}
}
async function fetchSettings() {
try {
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
settings.value = data
if (data?.rp_name) {
document.title = `${data.rp_name} · Access Restricted`
}
} catch (error) {
console.warn('Unable to load settings', error)
}
}
async function fetchUserInfo() {
try {
const res = await fetch('/auth/api/user-info', { method: 'POST' })
if (!res.ok) {
const payload = await safeParseJson(res)
fallbackDetail.value = payload?.detail || 'Please sign in to continue.'
return
}
userInfo.value = await res.json()
} catch (error) {
console.error('Failed to load user info', error)
fallbackDetail.value = 'We were unable to verify your session. Try again shortly.'
}
}
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) {
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) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
return
}
showMessage('Signed in successfully!', 'success', 2000)
setTimeout(() => {
loading.value = false
window.location.reload()
}, 800)
}
async function setSessionCookie(sessionToken) {
const response = await fetch('/auth/api/set-session', {
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)
}
return payload
}
function returnHome() {
const target = uiBasePath.value || '/auth/'
if (window.location.pathname !== target) {
history.replaceState(null, '', target)
}
window.location.href = target
}
async function safeParseJson(response) {
try {
return await response.json()
} catch (error) {
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 {
display: flex;
justify-content: center;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import RestrictedApp from './RestrictedApp.vue'
import '@/assets/style.css'
createApp(RestrictedApp).mount('#app')

View File

@@ -7,8 +7,6 @@ export const useAuthStore = defineStore('auth', {
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
settings: null, // Server provided settings (/auth/settings)
isLoading: false,
resetToken: null, // transient reset token
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
// UI State
currentView: 'login',
@@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', {
show: false
},
}),
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) {
this.isLoading = !!flag
},
showMessage(message, type = 'info', duration = 3000) {
this.status = {
message,
@@ -31,8 +43,17 @@ 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', {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${sessionToken}`},
})
@@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', {
if (result.detail) {
throw new Error(result.detail)
}
// On successful session establishment, discard any reset token to avoid
// sending stale Authorization headers on subsequent API calls.
this.resetToken = null
return result
},
async register() {
@@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', {
const result = await register()
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
this.isLoading = false
@@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', {
await this.setSessionCookie(result.session_token)
await this.loadUserInfo()
this.selectView()
return result
} finally {
@@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', {
}
},
selectView() {
if (this.restrictedMode) {
// In restricted mode only allow login or show permission denied if already authenticated
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
return
}
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
else this.currentView = 'login'
},
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
const response = await fetch(url, { method: 'POST', headers })
const response = await fetch('/auth/api/user-info', { method: 'POST' })
let result = null
try {
result = await response.json()

View File

@@ -35,6 +35,10 @@ export default defineConfig(({ command, mode }) => ({
if (url === '/auth/' || url === '/auth') return '/'
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'
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
// Everything else (including /auth/admin/* APIs) should proxy.
}
}
@@ -47,7 +51,9 @@ export default defineConfig(({ command, mode }) => ({
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin/index.html')
admin: resolve(__dirname, 'admin/index.html'),
reset: resolve(__dirname, 'reset/index.html'),
restricted: resolve(__dirname, 'restricted/index.html')
},
output: {}
}

View File

@@ -14,7 +14,7 @@ import uuid7
from . import authsession, globals
from .db import Org, Permission, Role, User
from .util import passphrase, tokens
from .util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger:
@@ -47,7 +47,8 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
expires=authsession.expires(),
info={"type": session_type},
)
reset_link = f"{globals.passkey.instance.origin}/auth/{token}"
base = hostutil.auth_site_base_url()
reset_link = f"{base}{token}"
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
return reset_link

View File

@@ -94,6 +94,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
)
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
p.add_argument(
"--auth-host",
help=(
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
" e.g. auth.example.com or https://auth.example.com"
),
)
def main():
@@ -168,6 +175,16 @@ def main():
os.environ["PASSKEY_RP_NAME"] = args.rp_name
if origin:
os.environ["PASSKEY_ORIGIN"] = origin
if getattr(args, "auth_host", None):
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
else:
# Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
if getattr(args, "auth_host", None):
from passkey.util import hostutil as _hostutil # local import
_hostutil.reload_config()
# One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled.

View File

@@ -6,7 +6,6 @@ from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
from . import authz
@@ -358,10 +357,8 @@ async def admin_create_user_registration_link(
expires=expires(),
info={"type": "device addition", "created_by_admin": True},
)
origin = hostutil.effective_origin(
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id
)
url = f"{origin}/auth/{token}"
base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
url = f"{base}{token}"
return {"url": url, "expires": expires().isoformat()}

View File

@@ -13,7 +13,7 @@ from fastapi import (
Request,
Response,
)
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from passkey.util import frontend
@@ -112,13 +112,20 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
}
return Response(status_code=204, headers=remote_headers)
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
html = frontend.file("restricted", "index.html").read_bytes()
return Response(html, status_code=e.status_code, media_type="text/html")
@app.get("/settings")
async def get_settings():
pk = global_passkey.instance
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
base_path = hostutil.ui_base_path()
return {
"rp_id": pk.rp_id,
"rp_name": pk.rp_name,
"ui_base_path": base_path,
"auth_host": hostutil.configured_auth_host(),
}
@app.post("/user-info")
@@ -267,10 +274,8 @@ async def api_create_link(request: Request, auth=Cookie(None)):
expires=expires(),
info=session.infodict(request, "device addition"),
)
origin = hostutil.effective_origin(
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id
)
url = f"{origin}/auth/{token}"
base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
url = f"{base}{token}"
return {
"message": "Registration link generated successfully",
"url": url,

View File

@@ -2,11 +2,11 @@ import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi import Cookie, FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, passphrase
from passkey.util import frontend, hostutil, passphrase
from . import admin, api, ws
@@ -53,26 +53,37 @@ app.mount(
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets"
)
# Navigable URLs are defined here. We support both / and /auth/ as the base path
# / is used on a dedicated auth site, /auth/ on app domains with auth
@app.get("/")
async def frontapp_redirect(request: Request):
"""Redirect root (in case accessed on backend) to the main authentication app."""
return RedirectResponse(request.url_for("frontapp"), status_code=303)
@app.get("/auth/")
async def frontapp():
"""Serve the main authentication app."""
return FileResponse(frontend.file("index.html"))
@app.get("/admin", include_in_schema=False)
@app.get("/auth/admin", include_in_schema=False)
async def admin_root_redirect():
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
@app.get("/admin/", include_in_schema=False)
async def admin_root(auth=Cookie(None)):
return await admin.adminapp(auth) # Delegate to handler of /auth/admin/
@app.get("/{reset}")
@app.get("/auth/{reset}")
async def reset_link(request: Request, reset: str):
"""Pretty URL for reset links."""
if reset == "admin":
# Admin app missing trailing slash lands here, be friendly to user
return RedirectResponse(request.url_for("adminapp"), status_code=303)
async def reset_link(reset: str):
"""Serve the SPA directly with an injected reset token."""
if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404)
url = request.url_for("frontapp").include_query_params(reset=reset)
return RedirectResponse(url, status_code=303)
return FileResponse(frontend.file("reset", "index.html"))
@app.get("/restricted", include_in_schema=False)
@app.get("/auth/restricted", include_in_schema=False)
async def restricted_view():
return FileResponse(frontend.file("restricted", "index.html"))

View File

@@ -17,7 +17,7 @@ from uuid import UUID
from passkey import authsession as _authsession
from passkey import globals as _g
from passkey.util import passphrase
from passkey.util import hostutil, passphrase
from passkey.util import tokens as _tokens
@@ -69,7 +69,8 @@ async def _create_reset(user, role_name: str):
expires=_authsession.expires(),
info={"type": "manual reset", "role": role_name},
)
return f"{_g.passkey.instance.origin}/auth/{token}", token
base = hostutil.auth_site_base_url()
return f"{base}{token}", token
async def _main(query: str | None) -> int:

View File

@@ -1,24 +1,67 @@
"""Utilities for host validation and origin determination."""
"""Utilities for determining the auth UI host and base URLs."""
import os
from functools import lru_cache
from urllib.parse import urlparse
from ..globals import passkey as global_passkey
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
def effective_origin(scheme: str, host: str | None, rp_id: str) -> str:
"""Determine the effective origin for a request.
Uses the provided host if it's compatible with the relying party ID,
otherwise falls back to the configured origin.
def _default_origin_scheme() -> str:
origin_url = urlparse(global_passkey.instance.origin)
return origin_url.scheme or "https"
Args:
scheme: The URL scheme (e.g. "https")
host: The host header value (e.g. "example.com" or "sub.example.com:8080")
rp_id: The relying party ID (e.g. "example.com")
Returns:
The effective origin URL to use
"""
@lru_cache(maxsize=1)
def _load_config() -> tuple[str | None, str] | None:
raw = os.getenv(_AUTH_HOST_ENV)
if not raw:
return None
candidate = raw.strip()
if not candidate:
return None
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
netloc = parsed.netloc or parsed.path
if not netloc:
return None
return (parsed.scheme or None, netloc.strip("/"))
def configured_auth_host() -> str | None:
cfg = _load_config()
return cfg[1] if cfg else None
def is_root_mode() -> bool:
return _load_config() is not None
def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/"
def _format_base_url(scheme: str, netloc: str) -> str:
scheme_part = scheme or _default_origin_scheme()
base = f"{scheme_part}://{netloc}"
return base if base.endswith("/") else f"{base}/"
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
cfg = _load_config()
if cfg:
cfg_scheme, cfg_host = cfg
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
return _format_base_url(scheme_to_use, cfg_host)
if host:
hostname = host.split(":")[0] # Remove port if present
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
return f"{scheme}://{host}"
return global_passkey.instance.origin
scheme_to_use = scheme or _default_origin_scheme()
return _format_base_url(scheme_to_use, host.strip("/"))
origin = global_passkey.instance.origin.rstrip("/")
return f"{origin}/auth/"
def reload_config() -> None:
_load_config.cache_clear()