Compare commits
	
		
			4 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 963ab06664 | ||
|   | bb35e57ba4 | ||
|   | 5d8304bbd9 | ||
|   | fbfd0bbb47 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -6,3 +6,4 @@ dist/ | ||||
| passkey-auth.sqlite | ||||
| /passkey/frontend-build | ||||
| /test_*.py | ||||
| passkey/_version.py | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
|   } 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,6 +43,15 @@ 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', { | ||||
|         method: 'POST', | ||||
| @@ -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,7 @@ 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}" | ||||
|     reset_link = hostutil.reset_link_url(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. | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import logging | ||||
| from uuid import UUID, uuid4 | ||||
|  | ||||
| from fastapi import Body, Cookie, FastAPI, HTTPException | ||||
| from fastapi import Body, Cookie, FastAPI, HTTPException, Request | ||||
| 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, passphrase, permutil, querysafe, tokens | ||||
| from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens | ||||
| from . import authz | ||||
|  | ||||
| app = FastAPI() | ||||
| @@ -335,7 +334,7 @@ async def admin_update_user_role( | ||||
|  | ||||
| @app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") | ||||
| async def admin_create_user_registration_link( | ||||
|     org_uuid: UUID, user_uuid: UUID, auth=Cookie(None) | ||||
|     org_uuid: UUID, user_uuid: UUID, request: Request, auth=Cookie(None) | ||||
| ): | ||||
|     try: | ||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) | ||||
| @@ -358,8 +357,9 @@ async def admin_create_user_registration_link( | ||||
|         expires=expires(), | ||||
|         info={"type": "device addition", "created_by_admin": True}, | ||||
|     ) | ||||
|     origin = global_passkey.instance.origin | ||||
|     url = f"{origin}/auth/{token}" | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     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 | ||||
| @@ -29,7 +29,7 @@ from ..authsession import ( | ||||
| ) | ||||
| from ..globals import db | ||||
| from ..globals import passkey as global_passkey | ||||
| from ..util import passphrase, permutil, tokens | ||||
| from ..util import hostutil, passphrase, permutil, tokens | ||||
| from ..util.tokens import session_key | ||||
| from . import authz, session | ||||
|  | ||||
| @@ -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,8 +274,9 @@ async def api_create_link(request: Request, auth=Cookie(None)): | ||||
|         expires=expires(), | ||||
|         info=session.infodict(request, "device addition"), | ||||
|     ) | ||||
|     origin = global_passkey.instance.origin.rstrip("/") | ||||
|     url = f"{origin}/auth/{token}" | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     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,7 @@ 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 | ||||
|     return hostutil.reset_link_url(token), token | ||||
|  | ||||
|  | ||||
| async def _main(query: str | None) -> int: | ||||
|   | ||||
							
								
								
									
										72
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								passkey/util/hostutil.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| """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 _default_origin_scheme() -> str: | ||||
|     origin_url = urlparse(global_passkey.instance.origin) | ||||
|     return origin_url.scheme or "https" | ||||
|  | ||||
|  | ||||
| @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 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() | ||||
|         netloc = cfg_host | ||||
|     else: | ||||
|         if host: | ||||
|             scheme_to_use = scheme or _default_origin_scheme() | ||||
|             netloc = host.strip("/") | ||||
|         else: | ||||
|             origin = global_passkey.instance.origin.rstrip("/") | ||||
|             return f"{origin}{ui_base_path()}" | ||||
|  | ||||
|     base = f"{scheme_to_use}://{netloc}".rstrip("/") | ||||
|     path = ui_base_path().lstrip("/") | ||||
|     return f"{base}/{path}" if path else f"{base}/" | ||||
|  | ||||
|  | ||||
| def reset_link_url( | ||||
|     token: str, scheme: str | None = None, host: str | None = None | ||||
| ) -> str: | ||||
|     base = auth_site_base_url(scheme, host) | ||||
|     return f"{base}{token}" | ||||
|  | ||||
|  | ||||
| def reload_config() -> None: | ||||
|     _load_config.cache_clear() | ||||
| @@ -1,10 +1,10 @@ | ||||
| [build-system] | ||||
| requires = ["hatchling"] | ||||
| requires = ["hatchling", "hatch-vcs"] | ||||
| build-backend = "hatchling.build" | ||||
|  | ||||
| [project] | ||||
| name = "passkey" | ||||
| version = "0.2.0" | ||||
| dynamic = ["version"] | ||||
| description = "Passkey Authentication for Web Services" | ||||
| authors = [ | ||||
|     {name = "Leo Vasanko"}, | ||||
| @@ -21,6 +21,12 @@ dependencies = [ | ||||
| ] | ||||
| requires-python = ">=3.10" | ||||
|  | ||||
| [tool.hatch.version] | ||||
| source = "vcs" | ||||
|  | ||||
| [tool.hatch.build.hooks.vcs] | ||||
| version-file = "passkey/_version.py" | ||||
|  | ||||
| [project.optional-dependencies] | ||||
| dev = [ | ||||
|     "ruff>=0.1.0", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user