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:
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication</title>
|
||||
<title>Auth Profile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
12
frontend/reset/index.html
Normal file
12
frontend/reset/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Complete Passkey Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/reset/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
frontend/restricted/index.html
Normal file
12
frontend/restricted/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Restricted</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/restricted/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,8 +7,6 @@
|
||||
<LoginView v-if="store.currentView === 'login'" />
|
||||
<ProfileView v-if="store.currentView === 'profile'" />
|
||||
<DeviceLinkView v-if="store.currentView === 'device-link'" />
|
||||
<ResetView v-if="store.currentView === 'reset'" />
|
||||
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
|
||||
</template>
|
||||
<!-- Show loading state while determining auth status -->
|
||||
<div v-else class="loading-container">
|
||||
@@ -26,21 +24,10 @@ import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoginView from '@/components/LoginView.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import DeviceLinkView from '@/components/DeviceLinkView.vue'
|
||||
import ResetView from '@/components/ResetView.vue'
|
||||
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
const initialized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
// Detect restricted mode:
|
||||
// We only allow full functionality on the exact /auth/ (or /auth) path.
|
||||
// Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted
|
||||
// so the app will only show login or permission denied views.
|
||||
const path = location.pathname
|
||||
if (!(path === '/auth/' || path === '/auth')) {
|
||||
store.setRestrictedMode(true)
|
||||
}
|
||||
// Load branding / settings first (non-blocking for auth flow)
|
||||
await store.loadSettings()
|
||||
// Was an error message passed in the URL hash?
|
||||
@@ -49,23 +36,11 @@ onMounted(async () => {
|
||||
store.showMessage(decodeURIComponent(message), 'error')
|
||||
history.replaceState(null, '', location.pathname)
|
||||
}
|
||||
// Capture reset token from query parameter and then remove it
|
||||
const params = new URLSearchParams(location.search)
|
||||
const reset = params.get('reset')
|
||||
if (reset) {
|
||||
store.resetToken = reset
|
||||
// Remove query param to avoid lingering in history / clipboard
|
||||
const targetPath = '/auth/'
|
||||
const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
|
||||
history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath)
|
||||
}
|
||||
try {
|
||||
await store.loadUserInfo()
|
||||
initialized.value = true
|
||||
store.selectView()
|
||||
} catch (error) {
|
||||
console.log('Failed to load user info:', error)
|
||||
store.currentView = 'login'
|
||||
} finally {
|
||||
initialized.value = true
|
||||
store.selectView()
|
||||
}
|
||||
|
||||
@@ -330,8 +330,8 @@ const pageHeading = computed(() => {
|
||||
// Breadcrumb entries for admin app.
|
||||
const breadcrumbEntries = computed(() => {
|
||||
const entries = [
|
||||
{ label: 'Auth', href: '/auth/' },
|
||||
{ label: 'Admin', href: '/auth/admin/' }
|
||||
{ label: 'Auth', href: authStore.uiHref() },
|
||||
{ label: 'Admin', href: authStore.adminHomeHref() }
|
||||
]
|
||||
// Determine organization for user view if selectedOrg not explicitly chosen.
|
||||
let orgForUser = null
|
||||
|
||||
@@ -162,7 +162,6 @@ a:focus-visible {
|
||||
|
||||
.view-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.85rem, 2.5vw + 1rem, 2.6rem);
|
||||
font-weight: 600;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,7 @@ const handleLogin = async () => {
|
||||
authStore.showMessage('Starting authentication...', 'info')
|
||||
await authStore.authenticate()
|
||||
authStore.showMessage('Authentication successful!', 'success', 2000)
|
||||
if (authStore.restrictedMode) {
|
||||
location.reload()
|
||||
} else if (location.pathname === '/auth/') {
|
||||
authStore.currentView = 'profile'
|
||||
} else {
|
||||
location.reload()
|
||||
}
|
||||
authStore.currentView = 'profile'
|
||||
} catch (error) {
|
||||
authStore.showMessage(error.message, 'error')
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<header class="view-header">
|
||||
<h1>👋 Welcome!</h1>
|
||||
<Breadcrumbs :entries="[{ label: 'Auth', href: '/auth/' }, ...(isAdmin ? [{ label: 'Admin', href: '/auth/admin/' }] : [])]" />
|
||||
<Breadcrumbs :entries="breadcrumbEntries" />
|
||||
<p class="view-lede">Manage your account details and passkeys.</p>
|
||||
</header>
|
||||
|
||||
@@ -144,6 +144,12 @@ const openNameDialog = () => {
|
||||
|
||||
const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin))
|
||||
|
||||
const breadcrumbEntries = computed(() => {
|
||||
const entries = [{ label: 'Auth', href: authStore.uiHref() }]
|
||||
if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() })
|
||||
return entries
|
||||
})
|
||||
|
||||
const saveName = async () => {
|
||||
const name = newName.value.trim()
|
||||
if (!name) {
|
||||
|
||||
@@ -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
frontend/src/reset/ResetApp.vue
Normal file
258
frontend/src/reset/ResetApp.vue
Normal 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
frontend/src/reset/main.js
Normal file
5
frontend/src/reset/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import ResetApp from './ResetApp.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(ResetApp).mount('#app')
|
||||
207
frontend/src/restricted/RestrictedApp.vue
Normal file
207
frontend/src/restricted/RestrictedApp.vue
Normal 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
frontend/src/restricted/main.js
Normal file
5
frontend/src/restricted/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import RestrictedApp from './RestrictedApp.vue'
|
||||
import '@/assets/style.css'
|
||||
|
||||
createApp(RestrictedApp).mount('#app')
|
||||
@@ -7,8 +7,6 @@ export const useAuthStore = defineStore('auth', {
|
||||
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
|
||||
settings: null, // Server provided settings (/auth/settings)
|
||||
isLoading: false,
|
||||
resetToken: null, // transient reset token
|
||||
restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied
|
||||
|
||||
// UI State
|
||||
currentView: 'login',
|
||||
@@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', {
|
||||
show: false
|
||||
},
|
||||
}),
|
||||
getters: {
|
||||
uiBasePath(state) {
|
||||
const configured = state.settings?.ui_base_path || '/auth/'
|
||||
if (!configured.endsWith('/')) return `${configured}/`
|
||||
return configured
|
||||
},
|
||||
adminUiPath() {
|
||||
const base = this.uiBasePath
|
||||
return base === '/' ? '/admin/' : `${base}admin/`
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setLoading(flag) {
|
||||
this.isLoading = !!flag
|
||||
},
|
||||
showMessage(message, type = 'info', duration = 3000) {
|
||||
this.status = {
|
||||
message,
|
||||
@@ -31,8 +43,17 @@ export const useAuthStore = defineStore('auth', {
|
||||
}, duration)
|
||||
}
|
||||
},
|
||||
uiHref(suffix = '') {
|
||||
const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix
|
||||
if (!trimmed) return this.uiBasePath
|
||||
if (this.uiBasePath === '/') return `/${trimmed}`
|
||||
return `${this.uiBasePath}${trimmed}`
|
||||
},
|
||||
adminHomeHref() {
|
||||
return this.adminUiPath
|
||||
},
|
||||
async setSessionCookie(sessionToken) {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
||||
})
|
||||
@@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', {
|
||||
if (result.detail) {
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
// On successful session establishment, discard any reset token to avoid
|
||||
// sending stale Authorization headers on subsequent API calls.
|
||||
this.resetToken = null
|
||||
return result
|
||||
},
|
||||
async register() {
|
||||
@@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
const result = await register()
|
||||
await this.setSessionCookie(result.session_token)
|
||||
await this.loadUserInfo()
|
||||
this.selectView()
|
||||
return result
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
@@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
|
||||
await this.setSessionCookie(result.session_token)
|
||||
await this.loadUserInfo()
|
||||
this.selectView()
|
||||
|
||||
return result
|
||||
} finally {
|
||||
@@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
selectView() {
|
||||
if (this.restrictedMode) {
|
||||
// In restricted mode only allow login or show permission denied if already authenticated
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
|
||||
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
|
||||
return
|
||||
}
|
||||
if (!this.userInfo) this.currentView = 'login'
|
||||
else if (this.userInfo.authenticated) this.currentView = 'profile'
|
||||
else this.currentView = 'reset'
|
||||
},
|
||||
setRestrictedMode(flag) {
|
||||
this.restrictedMode = !!flag
|
||||
else this.currentView = 'login'
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const headers = {}
|
||||
// Reset tokens are only passed via query param now, not Authorization header
|
||||
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
|
||||
const response = await fetch(url, { method: 'POST', headers })
|
||||
const response = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
let result = null
|
||||
try {
|
||||
result = await response.json()
|
||||
|
||||
@@ -35,6 +35,10 @@ export default defineConfig(({ command, mode }) => ({
|
||||
if (url === '/auth/' || url === '/auth') return '/'
|
||||
if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/'
|
||||
if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '')
|
||||
if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||
if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html'
|
||||
if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html'
|
||||
if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html'
|
||||
// Everything else (including /auth/admin/* APIs) should proxy.
|
||||
}
|
||||
}
|
||||
@@ -47,7 +51,9 @@ export default defineConfig(({ command, mode }) => ({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
admin: resolve(__dirname, 'admin/index.html')
|
||||
admin: resolve(__dirname, 'admin/index.html'),
|
||||
reset: resolve(__dirname, 'reset/index.html'),
|
||||
restricted: resolve(__dirname, 'restricted/index.html')
|
||||
},
|
||||
output: {}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import uuid7
|
||||
|
||||
from . import authsession, globals
|
||||
from .db import Org, Permission, Role, User
|
||||
from .util import passphrase, tokens
|
||||
from .util import hostutil, passphrase, tokens
|
||||
|
||||
|
||||
def _init_logger() -> logging.Logger:
|
||||
@@ -47,7 +47,8 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) ->
|
||||
expires=authsession.expires(),
|
||||
info={"type": session_type},
|
||||
)
|
||||
reset_link = f"{globals.passkey.instance.origin}/auth/{token}"
|
||||
base = hostutil.auth_site_base_url()
|
||||
reset_link = f"{base}{token}"
|
||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
@@ -94,6 +94,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)")
|
||||
p.add_argument("--origin", help="Origin URL (default: https://<rp-id>)")
|
||||
p.add_argument(
|
||||
"--auth-host",
|
||||
help=(
|
||||
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
|
||||
" e.g. auth.example.com or https://auth.example.com"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -168,6 +175,16 @@ def main():
|
||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
||||
if origin:
|
||||
os.environ["PASSKEY_ORIGIN"] = origin
|
||||
if getattr(args, "auth_host", None):
|
||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
||||
else:
|
||||
# Preserve pre-set env variable if CLI option omitted
|
||||
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST")
|
||||
|
||||
if getattr(args, "auth_host", None):
|
||||
from passkey.util import hostutil as _hostutil # local import
|
||||
|
||||
_hostutil.reload_config()
|
||||
|
||||
# One-time initialization + bootstrap before starting any server processes.
|
||||
# Lifespan in worker processes will call globals.init with bootstrap disabled.
|
||||
|
||||
@@ -6,7 +6,6 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from ..authsession import expires
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens
|
||||
from . import authz
|
||||
|
||||
@@ -358,10 +357,8 @@ async def admin_create_user_registration_link(
|
||||
expires=expires(),
|
||||
info={"type": "device addition", "created_by_admin": True},
|
||||
)
|
||||
origin = hostutil.effective_origin(
|
||||
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id
|
||||
)
|
||||
url = f"{origin}/auth/{token}"
|
||||
base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
|
||||
url = f"{base}{token}"
|
||||
return {"url": url, "expires": expires().isoformat()}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from fastapi import (
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer
|
||||
|
||||
from passkey.util import frontend
|
||||
@@ -112,13 +112,20 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None))
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
except HTTPException as e:
|
||||
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
|
||||
html = frontend.file("restricted", "index.html").read_bytes()
|
||||
return Response(html, status_code=e.status_code, media_type="text/html")
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
async def get_settings():
|
||||
pk = global_passkey.instance
|
||||
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
|
||||
base_path = hostutil.ui_base_path()
|
||||
return {
|
||||
"rp_id": pk.rp_id,
|
||||
"rp_name": pk.rp_name,
|
||||
"ui_base_path": base_path,
|
||||
"auth_host": hostutil.configured_auth_host(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
@@ -267,10 +274,8 @@ async def api_create_link(request: Request, auth=Cookie(None)):
|
||||
expires=expires(),
|
||||
info=session.infodict(request, "device addition"),
|
||||
)
|
||||
origin = hostutil.effective_origin(
|
||||
request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id
|
||||
)
|
||||
url = f"{origin}/auth/{token}"
|
||||
base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host"))
|
||||
url = f"{base}{token}"
|
||||
return {
|
||||
"message": "Registration link generated successfully",
|
||||
"url": url,
|
||||
|
||||
@@ -2,11 +2,11 @@ import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import Cookie, FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from passkey.util import frontend, passphrase
|
||||
from passkey.util import frontend, hostutil, passphrase
|
||||
|
||||
from . import admin, api, ws
|
||||
|
||||
@@ -53,26 +53,37 @@ app.mount(
|
||||
"/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets"
|
||||
)
|
||||
|
||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||
# / is used on a dedicated auth site, /auth/ on app domains with auth
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def frontapp_redirect(request: Request):
|
||||
"""Redirect root (in case accessed on backend) to the main authentication app."""
|
||||
return RedirectResponse(request.url_for("frontapp"), status_code=303)
|
||||
|
||||
|
||||
@app.get("/auth/")
|
||||
async def frontapp():
|
||||
"""Serve the main authentication app."""
|
||||
return FileResponse(frontend.file("index.html"))
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@app.get("/auth/admin", include_in_schema=False)
|
||||
async def admin_root_redirect():
|
||||
return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307)
|
||||
|
||||
|
||||
@app.get("/admin/", include_in_schema=False)
|
||||
async def admin_root(auth=Cookie(None)):
|
||||
return await admin.adminapp(auth) # Delegate to handler of /auth/admin/
|
||||
|
||||
|
||||
@app.get("/{reset}")
|
||||
@app.get("/auth/{reset}")
|
||||
async def reset_link(request: Request, reset: str):
|
||||
"""Pretty URL for reset links."""
|
||||
if reset == "admin":
|
||||
# Admin app missing trailing slash lands here, be friendly to user
|
||||
return RedirectResponse(request.url_for("adminapp"), status_code=303)
|
||||
async def reset_link(reset: str):
|
||||
"""Serve the SPA directly with an injected reset token."""
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise HTTPException(status_code=404)
|
||||
url = request.url_for("frontapp").include_query_params(reset=reset)
|
||||
return RedirectResponse(url, status_code=303)
|
||||
return FileResponse(frontend.file("reset", "index.html"))
|
||||
|
||||
|
||||
@app.get("/restricted", include_in_schema=False)
|
||||
@app.get("/auth/restricted", include_in_schema=False)
|
||||
async def restricted_view():
|
||||
return FileResponse(frontend.file("restricted", "index.html"))
|
||||
|
||||
@@ -17,7 +17,7 @@ from uuid import UUID
|
||||
|
||||
from passkey import authsession as _authsession
|
||||
from passkey import globals as _g
|
||||
from passkey.util import passphrase
|
||||
from passkey.util import hostutil, passphrase
|
||||
from passkey.util import tokens as _tokens
|
||||
|
||||
|
||||
@@ -69,7 +69,8 @@ async def _create_reset(user, role_name: str):
|
||||
expires=_authsession.expires(),
|
||||
info={"type": "manual reset", "role": role_name},
|
||||
)
|
||||
return f"{_g.passkey.instance.origin}/auth/{token}", token
|
||||
base = hostutil.auth_site_base_url()
|
||||
return f"{base}{token}", token
|
||||
|
||||
|
||||
async def _main(query: str | None) -> int:
|
||||
|
||||
@@ -1,24 +1,67 @@
|
||||
"""Utilities for host validation and origin determination."""
|
||||
"""Utilities for determining the auth UI host and base URLs."""
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..globals import passkey as global_passkey
|
||||
|
||||
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST"
|
||||
|
||||
def effective_origin(scheme: str, host: str | None, rp_id: str) -> str:
|
||||
"""Determine the effective origin for a request.
|
||||
|
||||
Uses the provided host if it's compatible with the relying party ID,
|
||||
otherwise falls back to the configured origin.
|
||||
def _default_origin_scheme() -> str:
|
||||
origin_url = urlparse(global_passkey.instance.origin)
|
||||
return origin_url.scheme or "https"
|
||||
|
||||
Args:
|
||||
scheme: The URL scheme (e.g. "https")
|
||||
host: The host header value (e.g. "example.com" or "sub.example.com:8080")
|
||||
rp_id: The relying party ID (e.g. "example.com")
|
||||
|
||||
Returns:
|
||||
The effective origin URL to use
|
||||
"""
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_config() -> tuple[str | None, str] | None:
|
||||
raw = os.getenv(_AUTH_HOST_ENV)
|
||||
if not raw:
|
||||
return None
|
||||
candidate = raw.strip()
|
||||
if not candidate:
|
||||
return None
|
||||
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
|
||||
netloc = parsed.netloc or parsed.path
|
||||
if not netloc:
|
||||
return None
|
||||
return (parsed.scheme or None, netloc.strip("/"))
|
||||
|
||||
|
||||
def configured_auth_host() -> str | None:
|
||||
cfg = _load_config()
|
||||
return cfg[1] if cfg else None
|
||||
|
||||
|
||||
def is_root_mode() -> bool:
|
||||
return _load_config() is not None
|
||||
|
||||
|
||||
def ui_base_path() -> str:
|
||||
return "/" if is_root_mode() else "/auth/"
|
||||
|
||||
|
||||
def _format_base_url(scheme: str, netloc: str) -> str:
|
||||
scheme_part = scheme or _default_origin_scheme()
|
||||
base = f"{scheme_part}://{netloc}"
|
||||
return base if base.endswith("/") else f"{base}/"
|
||||
|
||||
|
||||
def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str:
|
||||
cfg = _load_config()
|
||||
if cfg:
|
||||
cfg_scheme, cfg_host = cfg
|
||||
scheme_to_use = cfg_scheme or scheme or _default_origin_scheme()
|
||||
return _format_base_url(scheme_to_use, cfg_host)
|
||||
|
||||
if host:
|
||||
hostname = host.split(":")[0] # Remove port if present
|
||||
if hostname == rp_id or hostname.endswith(f".{rp_id}"):
|
||||
return f"{scheme}://{host}"
|
||||
return global_passkey.instance.origin
|
||||
scheme_to_use = scheme or _default_origin_scheme()
|
||||
return _format_base_url(scheme_to_use, host.strip("/"))
|
||||
|
||||
origin = global_passkey.instance.origin.rstrip("/")
|
||||
return f"{origin}/auth/"
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
_load_config.cache_clear()
|
||||
|
||||
Reference in New Issue
Block a user