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:
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ResetApp from './ResetApp.vue'
|
||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
createApp(ResetApp).mount('#app')
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import RestrictedApp from './RestrictedApp.vue'
|
||||||
|
import '@/assets/style.css'
|
||||||
|
|
||||||
|
createApp(RestrictedApp).mount('#app')
|
||||||
+28
-21
@@ -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()
|
||||||
|
|||||||
@@ -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: {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"))
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user