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:44:48 -06:00
parent fbfd0bbb47
commit 5d8304bbd9
23 changed files with 668 additions and 295 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication</title> <title>Auth Profile</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+12
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>
+12
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>
+1 -26
View File
@@ -7,8 +7,6 @@
<LoginView v-if="store.currentView === 'login'" /> <LoginView v-if="store.currentView === 'login'" />
<ProfileView v-if="store.currentView === 'profile'" /> <ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" /> <DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</template> </template>
<!-- Show loading state while determining auth status --> <!-- Show loading state while determining auth status -->
<div v-else class="loading-container"> <div v-else class="loading-container">
@@ -26,21 +24,10 @@ import StatusMessage from '@/components/StatusMessage.vue'
import LoginView from '@/components/LoginView.vue' import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue' import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore() const store = useAuthStore()
const initialized = ref(false) const initialized = ref(false)
onMounted(async () => { 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) // Load branding / settings first (non-blocking for auth flow)
await store.loadSettings() await store.loadSettings()
// Was an error message passed in the URL hash? // Was an error message passed in the URL hash?
@@ -49,23 +36,11 @@ onMounted(async () => {
store.showMessage(decodeURIComponent(message), 'error') store.showMessage(decodeURIComponent(message), 'error')
history.replaceState(null, '', location.pathname) 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 { try {
await store.loadUserInfo() await store.loadUserInfo()
initialized.value = true
store.selectView()
} catch (error) { } catch (error) {
console.log('Failed to load user info:', error) console.log('Failed to load user info:', error)
store.currentView = 'login' } finally {
initialized.value = true initialized.value = true
store.selectView() store.selectView()
} }
+2 -2
View File
@@ -330,8 +330,8 @@ const pageHeading = computed(() => {
// Breadcrumb entries for admin app. // Breadcrumb entries for admin app.
const breadcrumbEntries = computed(() => { const breadcrumbEntries = computed(() => {
const entries = [ const entries = [
{ label: 'Auth', href: '/auth/' }, { label: 'Auth', href: authStore.uiHref() },
{ label: 'Admin', href: '/auth/admin/' } { label: 'Admin', href: authStore.adminHomeHref() }
] ]
// 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
View File
@@ -162,7 +162,6 @@ a:focus-visible {
.view-header h1 { .view-header h1 {
margin: 0; margin: 0;
font-size: clamp(1.85rem, 2.5vw + 1rem, 2.6rem);
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-heading);
} }
+1 -7
View File
@@ -32,13 +32,7 @@ const handleLogin = async () => {
authStore.showMessage('Starting authentication...', 'info') authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate() await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000) authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) { authStore.currentView = 'profile'
location.reload()
} else if (location.pathname === '/auth/') {
authStore.currentView = 'profile'
} else {
location.reload()
}
} catch (error) { } catch (error) {
authStore.showMessage(error.message, 'error') authStore.showMessage(error.message, 'error')
} }
@@ -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>
+7 -1
View File
@@ -3,7 +3,7 @@
<div class="view-content"> <div class="view-content">
<header class="view-header"> <header class="view-header">
<h1>👋 Welcome!</h1> <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> <p class="view-lede">Manage your account details and passkeys.</p>
</header> </header>
@@ -144,6 +144,12 @@ const openNameDialog = () => {
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 saveName = async () => { const saveName = async () => {
const name = newName.value.trim() const name = newName.value.trim()
if (!name) { if (!name) {
-94
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>
+258
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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import ResetApp from './ResetApp.vue'
import '@/assets/style.css'
createApp(ResetApp).mount('#app')
+207
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>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import RestrictedApp from './RestrictedApp.vue'
import '@/assets/style.css'
createApp(RestrictedApp).mount('#app')
+28 -21
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} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
settings: null, // Server provided settings (/auth/settings) settings: null, // Server provided settings (/auth/settings)
isLoading: false, isLoading: false,
resetToken: null, // transient reset token
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
// UI State // UI State
currentView: 'login', currentView: 'login',
@@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', {
show: false 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: { actions: {
setLoading(flag) {
this.isLoading = !!flag
},
showMessage(message, type = 'info', duration = 3000) { showMessage(message, type = 'info', duration = 3000) {
this.status = { this.status = {
message, message,
@@ -31,8 +43,17 @@ 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',
headers: {'Authorization': `Bearer ${sessionToken}`}, headers: {'Authorization': `Bearer ${sessionToken}`},
}) })
@@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', {
if (result.detail) { if (result.detail) {
throw new Error(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 return result
}, },
async register() { async register() {
@@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', {
const result = await register() const result = await register()
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result.session_token)
await this.loadUserInfo() await this.loadUserInfo()
this.selectView()
return result return result
} finally { } finally {
this.isLoading = false this.isLoading = false
@@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', {
await this.setSessionCookie(result.session_token) await this.setSessionCookie(result.session_token)
await this.loadUserInfo() await this.loadUserInfo()
this.selectView()
return result return result
} finally { } finally {
@@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
selectView() { 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' if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile' else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset' else this.currentView = 'login'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
}, },
async loadUserInfo() { async loadUserInfo() {
const headers = {} const response = await fetch('/auth/api/user-info', { method: 'POST' })
// 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 })
let result = null let result = null
try { try {
result = await response.json() result = await response.json()
+7 -1
View File
@@ -35,6 +35,10 @@ export default defineConfig(({ command, mode }) => ({
if (url === '/auth/' || url === '/auth') return '/' if (url === '/auth/' || url === '/auth') return '/'
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 (/^\/([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. // Everything else (including /auth/admin/* APIs) should proxy.
} }
} }
@@ -47,7 +51,9 @@ export default defineConfig(({ command, mode }) => ({
rollupOptions: { rollupOptions: {
input: { input: {
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'),
restricted: resolve(__dirname, 'restricted/index.html')
}, },
output: {} output: {}
} }
+3 -2
View File
@@ -14,7 +14,7 @@ import uuid7
from . import authsession, globals from . import authsession, globals
from .db import Org, Permission, Role, User from .db import Org, Permission, Role, User
from .util import passphrase, tokens from .util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger: 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(), expires=authsession.expires(),
info={"type": session_type}, 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) logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
return reset_link return reset_link
+17
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("--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("--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(): def main():
@@ -168,6 +175,16 @@ def main():
os.environ["PASSKEY_RP_NAME"] = args.rp_name os.environ["PASSKEY_RP_NAME"] = args.rp_name
if origin: if origin:
os.environ["PASSKEY_ORIGIN"] = 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. # One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled. # Lifespan in worker processes will call globals.init with bootstrap disabled.
+2 -5
View File
@@ -6,7 +6,6 @@ from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires from ..authsession import expires
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
from . import authz from . import authz
@@ -358,10 +357,8 @@ async def admin_create_user_registration_link(
expires=expires(), expires=expires(),
info={"type": "device addition", "created_by_admin": True}, info={"type": "device addition", "created_by_admin": True},
) )
origin = hostutil.effective_origin( base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id url = f"{base}{token}"
)
url = f"{origin}/auth/{token}"
return {"url": url, "expires": expires().isoformat()} return {"url": url, "expires": expires().isoformat()}
+12 -7
View File
@@ -13,7 +13,7 @@ from fastapi import (
Request, Request,
Response, Response,
) )
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend 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) return Response(status_code=204, headers=remote_headers)
except HTTPException as e: 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") @app.get("/settings")
async def get_settings(): async def get_settings():
pk = global_passkey.instance 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") @app.post("/user-info")
@@ -267,10 +274,8 @@ async def api_create_link(request: Request, auth=Cookie(None)):
expires=expires(), expires=expires(),
info=session.infodict(request, "device addition"), info=session.infodict(request, "device addition"),
) )
origin = hostutil.effective_origin( base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id url = f"{base}{token}"
)
url = f"{origin}/auth/{token}"
return { return {
"message": "Registration link generated successfully", "message": "Registration link generated successfully",
"url": url, "url": url,
+26 -15
View File
@@ -2,11 +2,11 @@ import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request from fastapi import Cookie, FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, passphrase from passkey.util import frontend, hostutil, passphrase
from . import admin, api, ws from . import admin, api, ws
@@ -53,26 +53,37 @@ app.mount(
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets" "/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("/") @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/") @app.get("/auth/")
async def frontapp(): async def frontapp():
"""Serve the main authentication app."""
return FileResponse(frontend.file("index.html")) 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}") @app.get("/auth/{reset}")
async def reset_link(request: Request, reset: str): async def reset_link(reset: str):
"""Pretty URL for reset links.""" """Serve the SPA directly with an injected reset token."""
if reset == "admin":
# Admin app missing trailing slash lands here, be friendly to user
return RedirectResponse(request.url_for("adminapp"), status_code=303)
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
url = request.url_for("frontapp").include_query_params(reset=reset) return FileResponse(frontend.file("reset", "index.html"))
return RedirectResponse(url, status_code=303)
@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"))
+3 -2
View File
@@ -17,7 +17,7 @@ from uuid import UUID
from passkey import authsession as _authsession from passkey import authsession as _authsession
from passkey import globals as _g from passkey import globals as _g
from passkey.util import passphrase from passkey.util import hostutil, passphrase
from passkey.util import tokens as _tokens from passkey.util import tokens as _tokens
@@ -69,7 +69,8 @@ async def _create_reset(user, role_name: str):
expires=_authsession.expires(), expires=_authsession.expires(),
info={"type": "manual reset", "role": role_name}, 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: async def _main(query: str | None) -> int:
+59 -16
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 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, def _default_origin_scheme() -> str:
otherwise falls back to the configured origin. 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: @lru_cache(maxsize=1)
The effective origin URL to use 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: if host:
hostname = host.split(":")[0] # Remove port if present scheme_to_use = scheme or _default_origin_scheme()
if hostname == rp_id or hostname.endswith(f".{rp_id}"): return _format_base_url(scheme_to_use, host.strip("/"))
return f"{scheme}://{host}"
return global_passkey.instance.origin origin = global_passkey.instance.origin.rstrip("/")
return f"{origin}/auth/"
def reload_config() -> None:
_load_config.cache_clear()