Compare commits
	
		
			17 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 07525b47ae | ||
|   | 1ad1644b64 | ||
|   | 876215f1c1 | ||
|   | 59e7e40128 | ||
|   | a0da799c9e | ||
|   | 94efb00e34 | ||
|   | f9f4d59c6b | ||
|   | 45f9870d0d | ||
|   | 2a81544701 | ||
|   | a60c1bd5f5 | ||
|   | 229f066533 | ||
|   | 97f653e116 | ||
|   | 29be642dbe | ||
|   | bfb11cc20f | ||
|   | 389e05730b | ||
|   | 79b6c50a9c | ||
|   | 591ea626bf | 
							
								
								
									
										120
									
								
								API.md
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								API.md
									
									
									
									
									
								
							| @@ -1,28 +1,104 @@ | ||||
| # PassKey Auth API Documentation | ||||
|  | ||||
| This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required"). | ||||
| This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth | ||||
| service and how they behave depending on whether a dedicated authentication host | ||||
| (`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured. | ||||
|  | ||||
| ### HTTP Endpoints | ||||
| ## Base Paths & Host Modes | ||||
|  | ||||
| GET /auth/ - Main authentication app | ||||
| GET /auth/admin/ - Admin app for managing organisations, users and permissions | ||||
| GET /auth/{reset_token} - Process password reset/share token | ||||
| POST /auth/api/user-info - Get authenticated user information | ||||
| POST /auth/api/logout - Logout and delete session | ||||
| POST /auth/api/set-session - Set session cookie from Authorization header | ||||
| POST /auth/api/create-link - Create device addition link | ||||
| DELETE /auth/api/credential/{uuid} - Delete specific credential | ||||
| POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly) | ||||
| GET /auth/api/forward - Authentication validation for Caddy/Nginx | ||||
| 		- On success returns `204 No Content` with [user info](Headers.md) | ||||
| 		- Otherwise returns | ||||
| 		   * `401 Unauthorized` - authentication required | ||||
| 		   * `403 Forbidden` - missing required permissions | ||||
| 		   * Serves the authentication app for a login or permission denied page | ||||
| 		- Does not renew session! | ||||
| Two deployment modes: | ||||
|  | ||||
| ### WebAuthn/Passkey endpoints (WebSockets) | ||||
| 1. Multi‑host (default – no `--auth-host` provided) | ||||
|    - All endpoints are reachable on any host under the `/auth/` prefix. | ||||
|    - A convenience root (`/`) also serves the main app. | ||||
|  | ||||
| WS /auth/ws/register - Register new user with passkey | ||||
| WS /auth/ws/add_credential - Add new credential for existing user | ||||
| WS /auth/ws/authenticate - Authenticate user with passkey | ||||
| 2. Dedicated auth host (`--auth-host auth.example.com`) | ||||
|    - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.). | ||||
|    - Other (non‑auth) hosts show a lightweight account summary at `/` or `/auth/`, while other UI routes still redirect to the auth host. | ||||
|    - Restricted endpoints on non‑auth hosts return `404` instead of redirecting. | ||||
|  | ||||
| ### Path Mapping When Auth Host Enabled | ||||
|  | ||||
| | Purpose | On Auth Host | On Other Hosts (incoming) | Action | | ||||
| |---------|--------------|---------------------------|--------| | ||||
| | Main UI | `/` | `/auth/` or `/` | Serve account summary SPA (no redirect) | | ||||
| | Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) | | ||||
| | Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) | | ||||
| | Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) | | ||||
| | Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly | | ||||
| | Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts | | ||||
| | WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts | | ||||
|  | ||||
| Notes: | ||||
| - “Strip `/auth`” means only when the path starts with that exact segment. | ||||
| - A reset token is a single path segment validated by server logic; malformed tokens 404. | ||||
| - Method and body are preserved for UI redirects (307 Temporary Redirect). | ||||
|  | ||||
| ## HTTP UI Endpoints | ||||
|  | ||||
| | Method | Path (multi‑host) | Path (auth host) | Description | | ||||
| |--------|-------------------|------------------|-------------| | ||||
| | GET | `/auth/` | `/` | Main authentication SPA (non-auth hosts show an account summary view) | | ||||
| | GET | `/auth/admin/` | `/admin/` | Admin SPA root | | ||||
| | GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | | ||||
| | GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA | | ||||
|  | ||||
| ## Core API (Unrestricted – available on all hosts) | ||||
|  | ||||
| Always under `/auth/api/` (even on auth host): | ||||
|  | ||||
| | Method | Path | Description | | ||||
| |--------|------|-------------| | ||||
| | POST | `/auth/api/validate` | Validate & (conditionally) renew session | | ||||
| | GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) | | ||||
| | POST | `/auth/api/set-session` | Set cookie from Bearer token | | ||||
| | POST | `/auth/api/logout` | Logout current session | | ||||
| | POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) | | ||||
| | POST | `/auth/api/create-link` | Create a device addition link (reset token) | | ||||
| | DELETE | `/auth/api/credential/{uuid}` | Delete user credential | | ||||
| | DELETE | `/auth/api/session/{session_id}` | Terminate a specific session | | ||||
| | POST | `/auth/api/user/logout-all` | Terminate all sessions for the user | | ||||
| | PUT | `/auth/api/user/display-name` | Update display name | | ||||
|  | ||||
| ## Restricted API Namespaces | ||||
|  | ||||
| When `--auth-host` is set, requests to these paths on non‑auth hosts return 404: | ||||
|  | ||||
| | Namespace | Examples | | ||||
| |-----------|----------| | ||||
| | `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... | | ||||
| | `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) | | ||||
| | `/auth/api/ws` | (Reserved / future) | | ||||
|  | ||||
| ## WebSockets (Passkey) | ||||
|  | ||||
| | Path | Description | Host Mode Behavior | | ||||
| |------|-------------|--------------------| | ||||
| | `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured | | ||||
| | `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured | | ||||
|  | ||||
| ## Redirection & Status Codes | ||||
|  | ||||
| | Scenario | Response | | ||||
| |----------|----------| | ||||
| | UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped | | ||||
| | Reset token UI path on non‑auth host | 307 redirect (token preserved) | | ||||
| | Restricted API on non‑auth host | 404 | | ||||
| | Unrestricted API on any host | Normal response | | ||||
| | No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) | | ||||
|  | ||||
| ## Headers for /auth/api/forward | ||||
| See `Headers.md` for details of headers returned on success (204). | ||||
|  | ||||
| ## Notes for Integrators | ||||
| 1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced). | ||||
| 2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host. | ||||
| 3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site. | ||||
|  | ||||
| ## Environment & CLI Summary | ||||
| | Option | Effect | | ||||
| |--------|--------| | ||||
| | `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere | | ||||
|  | ||||
| --- | ||||
| This document reflects current behavior of the middleware-based host routing logic. | ||||
|   | ||||
							
								
								
									
										12
									
								
								frontend/host/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/host/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>Account Summary</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/host/main.js"></script> | ||||
|   </body> | ||||
| </html> | ||||
| @@ -2,13 +2,7 @@ | ||||
|   <div class="app-shell"> | ||||
|     <StatusMessage /> | ||||
|     <main class="app-main"> | ||||
|       <!-- Only render views after authentication status is determined --> | ||||
|       <template v-if="initialized"> | ||||
|         <LoginView v-if="store.currentView === 'login'" /> | ||||
|         <ProfileView v-if="store.currentView === 'profile'" /> | ||||
|         <DeviceLinkView v-if="store.currentView === 'device-link'" /> | ||||
|       </template> | ||||
|       <!-- Show loading state while determining auth status --> | ||||
|       <ProfileView v-if="initialized" /> | ||||
|       <div v-else class="loading-container"> | ||||
|         <div class="loading-spinner"></div> | ||||
|         <p>Loading...</p> | ||||
| @@ -21,58 +15,21 @@ | ||||
| import { onMounted, ref } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| import LoginView from '@/components/LoginView.vue' | ||||
| import ProfileView from '@/components/ProfileView.vue' | ||||
| import DeviceLinkView from '@/components/DeviceLinkView.vue' | ||||
| const store = useAuthStore() | ||||
| const initialized = ref(false) | ||||
|  | ||||
| onMounted(async () => { | ||||
|   // Load branding / settings first (non-blocking for auth flow) | ||||
|   await store.loadSettings() | ||||
|   // Was an error message passed in the URL hash? | ||||
|   const message = location.hash.substring(1) | ||||
|   if (message) { | ||||
|     store.showMessage(decodeURIComponent(message), 'error') | ||||
|     history.replaceState(null, '', location.pathname) | ||||
|   } | ||||
|   try { | ||||
|     await store.loadUserInfo() | ||||
|   } catch (error) { | ||||
|     console.log('Failed to load user info:', error) | ||||
|   } finally { | ||||
|     initialized.value = true | ||||
|     store.selectView() | ||||
|   } | ||||
|   if (store.settings?.rp_name) document.title = store.settings.rp_name | ||||
|   try { await store.loadUserInfo() } catch (_) { /* user info load errors ignored */ } | ||||
|   initialized.value = true | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .loading-container { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   height: 100vh; | ||||
|   gap: 1rem; | ||||
| } | ||||
|  | ||||
| .loading-spinner { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   border: 4px solid var(--color-border); | ||||
|   border-top: 4px solid var(--color-primary); | ||||
|   border-radius: 50%; | ||||
|   animation: spin 1s linear infinite; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|   0% { transform: rotate(0deg); } | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| .loading-container p { | ||||
|   color: var(--color-text-muted); | ||||
|   margin: 0; | ||||
| } | ||||
| .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; gap: 1rem; } | ||||
| .loading-spinner { width: 40px; height: 40px; border: 4px solid var(--color-border); border-top: 4px solid var(--color-primary); border-radius: 50%; animation: spin 1s linear infinite; } | ||||
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | ||||
| .loading-container p { color: var(--color-text-muted); margin: 0; } | ||||
| </style> | ||||
|   | ||||
| @@ -10,6 +10,7 @@ import AdminOrgDetail from './AdminOrgDetail.vue' | ||||
| import AdminUserDetail from './AdminUserDetail.vue' | ||||
| import AdminDialogs from './AdminDialogs.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' | ||||
|  | ||||
| const info = ref(null) | ||||
| const loading = ref(true) | ||||
| @@ -289,10 +290,8 @@ function deletePermission(p) { | ||||
|  | ||||
| onMounted(async () => { | ||||
|   window.addEventListener('hashchange', parseHash) | ||||
|   await authStore.loadSettings() | ||||
|   if (authStore.settings?.rp_name) { | ||||
|     document.title = authStore.settings.rp_name + ' Admin' | ||||
|   } | ||||
|   const settings = await getSettings() | ||||
|   if (settings?.rp_name) document.title = settings.rp_name + ' Admin' | ||||
|   load() | ||||
| }) | ||||
|  | ||||
| @@ -324,14 +323,14 @@ const selectedUser = computed(() => { | ||||
| const pageHeading = computed(() => { | ||||
|   if (selectedUser.value) return 'Admin: User' | ||||
|   if (selectedOrg.value) return 'Admin: Org' | ||||
|   return (authStore.settings?.rp_name || 'Master') + ' Admin' | ||||
|   return ((authStore.settings?.rp_name) || 'Master') + ' Admin' | ||||
| }) | ||||
|  | ||||
| // Breadcrumb entries for admin app. | ||||
| const breadcrumbEntries = computed(() => { | ||||
|   const entries = [ | ||||
|     { label: 'Auth', href: authStore.uiHref() }, | ||||
|     { label: 'Admin', href: authStore.adminHomeHref() } | ||||
|     { label: 'Auth', href: makeUiHref() }, | ||||
|     { label: 'Admin', href: adminUiPath() } | ||||
|   ] | ||||
|   // Determine organization for user view if selectedOrg not explicitly chosen. | ||||
|   let orgForUser = null | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import { ref } from 'vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import CredentialList from '@/components/CredentialList.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import SessionList from '@/components/SessionList.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
|  | ||||
| const props = defineProps({ | ||||
| @@ -57,18 +58,45 @@ function handleDelete(credential) { | ||||
|     /> | ||||
|     <div v-else-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> | ||||
|     <template v-if="userDetail && !userDetail.error"> | ||||
|       <h3 class="cred-title">Registered Passkeys</h3> | ||||
|       <CredentialList :credentials="userDetail.credentials" :aaguid-info="userDetail.aaguid_info" :allow-delete="true" @delete="handleDelete" /> | ||||
|       <div class="registration-actions"> | ||||
|         <button | ||||
|           class="btn-secondary reg-token-btn" | ||||
|           @click="$emit('generateUserRegistrationLink', selectedUser)" | ||||
|           :disabled="loading" | ||||
|         >Generate Registration Token</button> | ||||
|         <p class="matrix-hint muted"> | ||||
|           Generate a one-time registration link so this user can register or add another passkey. | ||||
|           Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device. | ||||
|         </p> | ||||
|       </div> | ||||
|       <section class="section-block" data-section="registered-passkeys"> | ||||
|         <div class="section-header"> | ||||
|           <h2>Registered Passkeys</h2> | ||||
|         </div> | ||||
|         <div class="section-body"> | ||||
|           <CredentialList | ||||
|             :credentials="userDetail.credentials" | ||||
|             :aaguid-info="userDetail.aaguid_info" | ||||
|             :allow-delete="true" | ||||
|             @delete="handleDelete" | ||||
|           /> | ||||
|         </div> | ||||
|       </section> | ||||
|       <SessionList | ||||
|         :sessions="userDetail.sessions || []" | ||||
|         :allow-terminate="false" | ||||
|         :empty-message="'This user has no active sessions.'" | ||||
|         :section-description="'View the active sessions for this user.'" | ||||
|       /> | ||||
|     </template> | ||||
|     <div class="actions"> | ||||
|       <button @click="$emit('generateUserRegistrationLink', selectedUser)">Generate Registration Token</button> | ||||
|     <div class="actions ancillary-actions"> | ||||
|       <button v-if="selectedOrg" @click="$emit('openOrg', selectedOrg)" class="icon-btn" title="Back to Org">↩️</button> | ||||
|     </div> | ||||
|     <p class="matrix-hint muted">Use the token dialog to register a new credential for the member.</p> | ||||
|     <RegistrationLinkModal | ||||
|       v-if="showRegModal" | ||||
|       :endpoint="`/auth/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`" | ||||
|       :auto-copy="false" | ||||
|       :user-name="userDetail?.display_name || selectedUser.display_name" | ||||
|       @close="$emit('closeRegModal')" | ||||
|       @copied="onLinkCopied" | ||||
|     /> | ||||
| @@ -77,9 +105,10 @@ function handleDelete(credential) { | ||||
|  | ||||
| <style scoped> | ||||
| .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } | ||||
| .cred-title { font-size: 1.25rem; font-weight: 600; color: var(--color-heading); margin-bottom: var(--space-md); } | ||||
| .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } | ||||
| .actions button { width: auto; } | ||||
| .ancillary-actions { margin-top: -0.5rem; } | ||||
| .reg-token-btn { align-self: flex-start; } | ||||
| .registration-actions { display: flex; flex-direction: column; gap: 0.5rem; } | ||||
| .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } | ||||
| .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } | ||||
| .matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); } | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| /* Passkey Authentication – Unified Layout */ | ||||
|  | ||||
| :root { | ||||
|   color-scheme: light dark; | ||||
| @@ -440,60 +439,119 @@ th { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-list { | ||||
| :root { --card-width: 22rem; } | ||||
|  | ||||
| .record-list, | ||||
| .credential-list, | ||||
| .session-list { | ||||
|   width: 100%; | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: repeat(auto-fit, var(--card-width)); | ||||
|   justify-content: start; | ||||
|   gap: 1rem 1.25rem; | ||||
|   align-items: stretch; | ||||
|   margin: 0 auto; | ||||
|   max-width: calc(var(--card-width) * 4 + 3 * 1.25rem); | ||||
| } | ||||
|  | ||||
| .credential-item { | ||||
| @media (max-width: 1100px) { | ||||
|   .record-list, | ||||
|   .credential-list, | ||||
|   .session-list { max-width: calc(var(--card-width) * 3 + 2 * 1.25rem); } | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   .record-list { display: flex; flex-direction: column; max-width: 100%; } | ||||
| } | ||||
|  | ||||
| .record-item, | ||||
| .credential-item, | ||||
| .session-item { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.75rem; | ||||
|   padding: 0.85rem 1rem; | ||||
|   padding: 1rem; | ||||
|   border: 1px solid var(--color-border); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border-radius: var(--radius-md); | ||||
|   background: var(--color-surface); | ||||
|   height: 100%; | ||||
|   transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .credential-item.current-session { | ||||
|   border-color: var(--color-accent); | ||||
|   background: rgba(37, 99, 235, 0.08); | ||||
| .record-item:hover, | ||||
| .credential-item:hover, | ||||
| .session-item:hover { | ||||
|   border-color: var(--color-border-strong); | ||||
|   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); | ||||
|   transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .credential-header { | ||||
| .record-item.is-current, | ||||
| .credential-item.current-session, | ||||
| .session-item.is-current { border-color: var(--color-accent); } | ||||
|  | ||||
| .item-top { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 1rem; | ||||
|   align-items: flex-start; | ||||
|   flex-wrap: wrap; | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
|  | ||||
| .credential-icon { | ||||
| .item-icon { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   background: var(--color-surface-subtle, transparent); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border: 1px solid var(--color-border); | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .credential-info { | ||||
|   flex: 1 1 auto; | ||||
| .auth-icon { | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .credential-info h4 { | ||||
| .item-title { | ||||
|   flex: 1; | ||||
|   margin: 0; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 600; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| .item-actions { | ||||
|   flex-shrink: 0; | ||||
|   display: flex; | ||||
|   gap: 0.5rem; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .item-actions .badge + .btn-card-delete { margin-left: 0.25rem; } | ||||
| .item-actions .badge + .badge { margin-left: 0.25rem; } | ||||
|  | ||||
| .item-details { | ||||
|   margin-left: calc(40px + 1rem); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.5rem; | ||||
| } | ||||
|  | ||||
| .credential-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   grid-template-columns: 7rem 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   color: var(--color-text-muted); | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .session-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: 7rem 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   color: var(--color-text-muted); | ||||
| @@ -509,27 +567,49 @@ th { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-actions { | ||||
|   margin-left: auto; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| .btn-card-delete { background: transparent; border: none; color: var(--color-danger); padding: 0.35rem 0.5rem; font-size: 1.05rem; line-height: 1; border-radius: var(--radius-sm); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } | ||||
| .btn-card-delete:hover:not(:disabled) { background: rgba(220, 38, 38, 0.08); } | ||||
| .btn-card-delete:disabled { opacity: 0.4; cursor: not-allowed; } | ||||
|  | ||||
|  | ||||
| .session-emoji { | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential { | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--color-danger); | ||||
|   padding: 0.25rem 0.35rem; | ||||
|   font-size: 1.05rem; | ||||
| .badge { | ||||
|   padding: 0.2rem 0.5rem; | ||||
|   border-radius: var(--radius-sm); | ||||
|   font-size: 0.8rem; | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:hover:not(:disabled) { | ||||
|   background: rgba(220, 38, 38, 0.08); | ||||
| .badge-current { | ||||
|   background: var(--color-accent); | ||||
|   color: var(--color-accent-contrast); | ||||
|   box-shadow: 0 0 0 1px var(--color-accent) inset; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: not-allowed; | ||||
| .badge:not(.badge-current) { | ||||
|   background: var(--color-surface-subtle); | ||||
|   color: var(--color-text-muted); | ||||
|   border: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
|  | ||||
| .session-meta-info { | ||||
|   font-size: 0.75rem; | ||||
|   color: var(--color-text-muted); | ||||
|   font-family: monospace; | ||||
| } | ||||
|  | ||||
| .empty-state { | ||||
|   text-align: center; | ||||
|   padding: var(--space-lg); | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .empty-state p { | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| .user-info { | ||||
| @@ -597,7 +677,6 @@ th { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Dialog styles for auth views */ | ||||
| .dialog-backdrop { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   | ||||
| @@ -6,10 +6,10 @@ | ||||
|       <div | ||||
|         v-for="credential in credentials" | ||||
|         :key="credential.credential_uuid" | ||||
|         :class="['credential-item', { 'current-session': credential.is_current_session }]" | ||||
|         :class="['credential-item', { 'current-session': credential.is_current_session } ]" | ||||
|       > | ||||
|         <div class="credential-header"> | ||||
|           <div class="credential-icon"> | ||||
|         <div class="item-top"> | ||||
|           <div class="item-icon"> | ||||
|             <img | ||||
|               v-if="getCredentialAuthIcon(credential)" | ||||
|               :src="getCredentialAuthIcon(credential)" | ||||
| @@ -20,24 +20,28 @@ | ||||
|             > | ||||
|             <span v-else class="auth-emoji">🔑</span> | ||||
|           </div> | ||||
|           <div class="credential-info"> | ||||
|             <h4>{{ getCredentialAuthName(credential) }}</h4> | ||||
|           </div> | ||||
|           <div class="credential-dates"> | ||||
|             <span class="date-label">Created:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||
|             <span class="date-label" v-if="credential.last_used">Last used:</span> | ||||
|             <span class="date-value" v-if="credential.last_used">{{ formatDate(credential.last_used) }}</span> | ||||
|           </div> | ||||
|           <div class="credential-actions" v-if="allowDelete"> | ||||
|           <h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4> | ||||
|           <div class="item-actions"> | ||||
|             <span v-if="credential.is_current_session" class="badge badge-current">Current</span> | ||||
|             <button | ||||
|               v-if="allowDelete" | ||||
|               @click="$emit('delete', credential)" | ||||
|               class="btn-delete-credential" | ||||
|               class="btn-card-delete" | ||||
|               :disabled="credential.is_current_session" | ||||
|               :title="credential.is_current_session ? 'Cannot delete current session credential' : 'Delete passkey'" | ||||
|             >🗑️</button> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="item-details"> | ||||
|           <div class="credential-dates"> | ||||
|             <span class="date-label">Created:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.created_at) }}</span> | ||||
|             <span class="date-label">Last used:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.last_used) }}</span> | ||||
|             <span class="date-label">Last verified:</span> | ||||
|             <span class="date-value">{{ formatDate(credential.last_verified) }}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| @@ -67,121 +71,3 @@ const getCredentialAuthIcon = (credential) => { | ||||
|   return info[iconKey] || null | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .credential-list { | ||||
|   width: 100%; | ||||
|   margin-top: var(--space-sm); | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | ||||
|   gap: 1rem 1.25rem; | ||||
|   align-items: stretch; | ||||
| } | ||||
|  | ||||
| .credential-item { | ||||
|   border: 1px solid var(--color-border); | ||||
|   border-radius: var(--radius-sm); | ||||
|   padding: 0.85rem 1rem; | ||||
|   background: var(--color-surface); | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.75rem; | ||||
|   width: 28rem; | ||||
|   height: 100%; | ||||
|   transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; | ||||
| } | ||||
|  | ||||
| .credential-item:hover { | ||||
|   border-color: var(--color-border-strong); | ||||
|   box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); | ||||
|   transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .credential-item.current-session { | ||||
|   border-color: var(--color-accent); | ||||
|   background: rgba(37, 99, 235, 0.08); | ||||
| } | ||||
|  | ||||
| .credential-header { | ||||
|   display: flex; | ||||
|   align-items: flex-start; | ||||
|   gap: 1rem; | ||||
|   flex-wrap: wrap; | ||||
|   flex: 1 1 auto; | ||||
| } | ||||
|  | ||||
| .credential-icon { | ||||
|   width: 40px; | ||||
|   height: 40px; | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   background: var(--color-surface-subtle, transparent); | ||||
|   border-radius: var(--radius-sm); | ||||
|   border: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
| .auth-icon { | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .credential-info { | ||||
|   flex: 1 1 150px; | ||||
|   min-width: 0; | ||||
| } | ||||
|  | ||||
| .credential-info h4 { | ||||
|   margin: 0; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 600; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| .credential-dates { | ||||
|   display: grid; | ||||
|   grid-auto-flow: row; | ||||
|   grid-template-columns: auto 1fr; | ||||
|   gap: 0.35rem 0.5rem; | ||||
|   font-size: 0.75rem; | ||||
|   align-items: center; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .date-label { | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .date-value { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| .credential-actions { | ||||
|   margin-left: auto; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .btn-delete-credential { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   cursor: pointer; | ||||
|   font-size: 1rem; | ||||
|   color: var(--color-danger); | ||||
|   padding: 0.25rem 0.35rem; | ||||
|   border-radius: var(--radius-sm); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:hover:not(:disabled) { | ||||
|   background: rgba(220, 38, 38, 0.08); | ||||
| } | ||||
|  | ||||
| .btn-delete-credential:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| @media (max-width: 600px) { | ||||
|   .credential-list { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -5,74 +5,39 @@ | ||||
|         <h1>📱 Add Another Device</h1> | ||||
|         <p class="view-lede">Generate a one-time link to set up passkeys on a new device.</p> | ||||
|       </header> | ||||
|       <section class="section-block"> | ||||
|         <div class="section-body"> | ||||
|           <div class="device-link-section"> | ||||
|             <div class="qr-container"> | ||||
|               <a :href="url" class="qr-link" @click="copyLink"> | ||||
|                 <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||
|                 <p v-if="url"> | ||||
|                   {{ url.replace(/^[^:]+:\/\//, '') }} | ||||
|                 </p> | ||||
|                 <p v-else> | ||||
|                   <em>Generating link...</em> | ||||
|                 </p> | ||||
|               </a> | ||||
|               <p> | ||||
|                 <strong>Scan and visit the URL on another device.</strong><br> | ||||
|                 <small>⚠️ Expires in 24 hours and can only be used once.</small> | ||||
|               </p> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="button-row"> | ||||
|             <button @click="authStore.currentView = 'profile'" class="btn-secondary"> | ||||
|               Back to Profile | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
|       <RegistrationLinkModal | ||||
|         inline | ||||
|                 :endpoint="'/auth/api/user/create-link'" | ||||
|         :user-name="userName" | ||||
|         :auto-copy="false" | ||||
|         :prefix-copy-with-user-name="!!userName" | ||||
|         show-close-in-inline | ||||
|         @copied="onCopied" | ||||
|       /> | ||||
|       <div class="button-row" style="margin-top:1rem;"> | ||||
|         <button @click="authStore.currentView = 'profile'" class="btn-secondary">Back to Profile</button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, nextTick } from 'vue' | ||||
| import { ref, onMounted } from 'vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import QRCode from 'qrcode/lib/browser' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const url = ref(null) | ||||
| const qrCanvas = ref(null) | ||||
|  | ||||
| const copyLink = async (event) => { | ||||
|   event.preventDefault() | ||||
|   if (url.value) { | ||||
|     await navigator.clipboard.writeText(url.value) | ||||
|     authStore.showMessage('Link copied to clipboard!') | ||||
|     authStore.currentView = 'profile' | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function drawQr() { | ||||
|   if (!url.value || !qrCanvas.value) return | ||||
|   await nextTick() | ||||
|   QRCode.toCanvas(qrCanvas.value, url.value, { scale: 8 }, (error) => { | ||||
|     if (error) console.error('Failed to generate QR code:', error) | ||||
|   }) | ||||
| const userName = ref(null) | ||||
| const onCopied = () => { | ||||
|   authStore.showMessage('Link copied to clipboard!', 'success', 2500) | ||||
|   authStore.currentView = 'profile' | ||||
| } | ||||
|  | ||||
| onMounted(async () => { | ||||
|   try { | ||||
|     const response = await fetch('/auth/api/create-link', { method: 'POST' }) | ||||
|     const result = await response.json() | ||||
|     if (result.detail) throw new Error(result.detail) | ||||
|  | ||||
|     url.value = result.url | ||||
|     await drawQr() | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(`Failed to create device link: ${error.message}`, 'error') | ||||
|     authStore.currentView = 'profile' | ||||
|   } | ||||
|   // Extract optional admin-provided query parameters (?user=Name&emoji=😀) | ||||
|   const params = new URLSearchParams(location.search) | ||||
|   const qUser = params.get('user') | ||||
|   if (qUser) userName.value = qUser.trim() | ||||
| }) | ||||
|  | ||||
| </script> | ||||
|   | ||||
| @@ -1,57 +0,0 @@ | ||||
| <template> | ||||
|   <div class="dialog-backdrop"> | ||||
|     <div class="dialog-container"> | ||||
|       <div class="dialog-content dialog-content--narrow"> | ||||
|         <header class="view-header"> | ||||
|           <h1>🔐 {{ (authStore.settings?.rp_name || location.origin)}}</h1> | ||||
|           <p class="view-lede">User authentication is required for access.</p> | ||||
|         </header> | ||||
|         <section class="section-block"> | ||||
|           <form class="section-body" @submit.prevent="handleLogin"> | ||||
|             <button | ||||
|               type="submit" | ||||
|               class="btn-primary" | ||||
|               :disabled="authStore.isLoading" | ||||
|             > | ||||
|               {{ authStore.isLoading ? 'Authenticating...' : 'Login with Your Device' }} | ||||
|             </button> | ||||
|           </form> | ||||
|         </section> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
|  | ||||
| const handleLogin = async () => { | ||||
|   try { | ||||
|     authStore.showMessage('Starting authentication...', 'info') | ||||
|     await authStore.authenticate() | ||||
|     authStore.showMessage('Authentication successful!', 'success', 2000) | ||||
|     authStore.currentView = 'profile' | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(error.message, 'error') | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .view-lede { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .section-body { | ||||
|   gap: 1.5rem; | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   button { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -3,7 +3,7 @@ | ||||
|     <div class="view-content"> | ||||
|       <header class="view-header"> | ||||
|         <h1>👋 Welcome!</h1> | ||||
|   <Breadcrumbs :entries="breadcrumbEntries" /> | ||||
|         <Breadcrumbs :entries="breadcrumbEntries" /> | ||||
|         <p class="view-lede">Manage your account details and passkeys.</p> | ||||
|       </header> | ||||
|  | ||||
| @@ -35,25 +35,19 @@ | ||||
|             @delete="handleDelete" | ||||
|           /> | ||||
|           <div class="button-row"> | ||||
|             <button @click="addNewCredential" class="btn-primary"> | ||||
|               Add New Passkey | ||||
|             </button> | ||||
|             <button @click="authStore.currentView = 'device-link'" class="btn-secondary"> | ||||
|               Add Another Device | ||||
|             </button> | ||||
|             <button @click="addNewCredential" class="btn-primary">Add New Passkey</button> | ||||
|             <button @click="showRegLink = true" class="btn-secondary">Add Another Device</button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </section> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="button-row"> | ||||
|           <button @click="logout" class="btn-danger logout-button"> | ||||
|             Logout | ||||
|           </button> | ||||
|         </div> | ||||
|       </section> | ||||
|       <SessionList | ||||
|         :sessions="sessions" | ||||
|         :terminating-sessions="terminatingSessions" | ||||
|         @terminate="terminateSession" | ||||
|         section-description="Review where you're signed in and end any sessions you no longer recognize." | ||||
|       /> | ||||
|  | ||||
|       <!-- Name Edit Dialog --> | ||||
|       <Modal v-if="showNameDialog" @close="showNameDialog = false"> | ||||
|         <h3>Edit Display Name</h3> | ||||
|         <form @submit.prevent="saveName" class="modal-form"> | ||||
| @@ -65,6 +59,33 @@ | ||||
|           /> | ||||
|         </form> | ||||
|       </Modal> | ||||
|  | ||||
|       <section class="section-block"> | ||||
|         <div class="button-row logout-row" :class="{ single: !hasMultipleSessions }"> | ||||
|           <button | ||||
|             type="button" | ||||
|             class="btn-secondary" | ||||
|             @click="history.back()" | ||||
|           > | ||||
|             Back | ||||
|           </button> | ||||
|           <button v-if="!hasMultipleSessions" @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button> | ||||
|           <template v-else> | ||||
|             <button @click="logout" class="btn-danger logout-button" :disabled="authStore.isLoading">Logout</button> | ||||
|             <button @click="logoutEverywhere" class="btn-danger logout-button" :disabled="authStore.isLoading">All</button> | ||||
|           </template> | ||||
|         </div> | ||||
|         <p class="logout-note" v-if="!hasMultipleSessions"><strong>Logout</strong> from {{ currentSessionHost }}.</p> | ||||
|         <p class="logout-note" v-else><strong>Logout</strong> this session on {{ currentSessionHost }}, or <strong>All</strong> sessions across all sites and devices for {{ rpName }}. You'll need to log in again with your passkey afterwards.</p> | ||||
|       </section> | ||||
|       <RegistrationLinkModal | ||||
|         v-if="showRegLink" | ||||
|                 :endpoint="'/auth/api/user/create-link'" | ||||
|         :auto-copy="false" | ||||
|         :prefix-copy-with-user-name="false" | ||||
|         @close="showRegLink = false" | ||||
|         @copied="showRegLink = false; authStore.showMessage('Link copied to clipboard!', 'success', 2500)" | ||||
|       /> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
| @@ -76,35 +97,26 @@ import CredentialList from '@/components/CredentialList.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import Modal from '@/components/Modal.vue' | ||||
| import NameEditForm from '@/components/NameEditForm.vue' | ||||
| import SessionList from '@/components/SessionList.vue' | ||||
| import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
| import { adminUiPath, makeUiHref } from '@/utils/settings' | ||||
| import passkey from '@/utils/passkey' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const updateInterval = ref(null) | ||||
| const showNameDialog = ref(false) | ||||
| const showRegLink = ref(false) | ||||
| const newName = ref('') | ||||
| const saving = ref(false) | ||||
|  | ||||
| watch(showNameDialog, (newVal) => { | ||||
|   if (newVal) { | ||||
|     newName.value = authStore.userInfo?.user?.user_name || '' | ||||
|   } | ||||
| }) | ||||
| watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   updateInterval.value = setInterval(() => { | ||||
|     // Trigger Vue reactivity to update formatDate fields | ||||
|     if (authStore.userInfo) { | ||||
|       authStore.userInfo = { ...authStore.userInfo } | ||||
|     } | ||||
|   }, 60000) // Update every minute | ||||
|   updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) | ||||
| }) | ||||
|  | ||||
| onUnmounted(() => { | ||||
|   if (updateInterval.value) { | ||||
|     clearInterval(updateInterval.value) | ||||
|   } | ||||
| }) | ||||
| onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) | ||||
|  | ||||
| const addNewCredential = async () => { | ||||
|   try { | ||||
| @@ -116,9 +128,7 @@ const addNewCredential = async () => { | ||||
|   } catch (error) { | ||||
|     console.error('Failed to add new passkey:', error) | ||||
|     authStore.showMessage(error.message, 'error') | ||||
|   } finally { | ||||
|     authStore.isLoading = false | ||||
|   } | ||||
|   } finally { authStore.isLoading = false } | ||||
| } | ||||
|  | ||||
| const handleDelete = async (credential) => { | ||||
| @@ -128,80 +138,62 @@ const handleDelete = async (credential) => { | ||||
|   try { | ||||
|     await authStore.deleteCredential(credentialId) | ||||
|     authStore.showMessage('Passkey deleted successfully!', 'success', 3000) | ||||
|   } catch (error) { | ||||
|     authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') | ||||
|   } catch (error) { authStore.showMessage(`Failed to delete passkey: ${error.message}`, 'error') } | ||||
| } | ||||
|  | ||||
| const rpName = computed(() => authStore.settings?.rp_name || 'this service') | ||||
| const sessions = computed(() => authStore.userInfo?.sessions || []) | ||||
| const currentSessionHost = computed(() => { | ||||
|   const currentSession = sessions.value.find(session => session.is_current) | ||||
|   return currentSession?.host || 'this host' | ||||
| }) | ||||
| const terminatingSessions = ref({}) | ||||
|  | ||||
| const terminateSession = async (session) => { | ||||
|   const sessionId = session?.id | ||||
|   if (!sessionId) return | ||||
|   terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } | ||||
|   try { await authStore.terminateSession(sessionId) } | ||||
|   catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) } | ||||
|   finally { | ||||
|     const next = { ...terminatingSessions.value } | ||||
|     delete next[sessionId] | ||||
|     terminatingSessions.value = next | ||||
|   } | ||||
| } | ||||
|  | ||||
| const logout = async () => { | ||||
|   await authStore.logout() | ||||
| } | ||||
|  | ||||
| const openNameDialog = () => { | ||||
|   newName.value = authStore.userInfo?.user?.user_name || '' | ||||
|   showNameDialog.value = true | ||||
| } | ||||
|  | ||||
| const logoutEverywhere = async () => { await authStore.logoutEverywhere() } | ||||
| const logout = async () => { await authStore.logout() } | ||||
| const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true } | ||||
| 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 hasMultipleSessions = computed(() => sessions.value.length > 1) | ||||
| const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries }) | ||||
|  | ||||
| const saveName = async () => { | ||||
|   const name = newName.value.trim() | ||||
|   if (!name) { | ||||
|     authStore.showMessage('Name cannot be empty', 'error') | ||||
|     return | ||||
|   } | ||||
|   if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return } | ||||
|   try { | ||||
|     saving.value = true | ||||
|     const res = await fetch('/auth/api/user/display-name', { | ||||
|       method: 'PUT', | ||||
|       headers: { 'content-type': 'application/json' }, | ||||
|       body: JSON.stringify({ display_name: name }) | ||||
|     }) | ||||
|     const res = await fetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) }) | ||||
|     const data = await res.json() | ||||
|     if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed') | ||||
|   showNameDialog.value = false | ||||
|     showNameDialog.value = false | ||||
|     await authStore.loadUserInfo() | ||||
|     authStore.showMessage('Name updated successfully!', 'success', 3000) | ||||
|   } catch (e) { | ||||
|     authStore.showMessage(e.message || 'Failed to update name', 'error') | ||||
|   } finally { | ||||
|     saving.value = false | ||||
|   } | ||||
|   } catch (e) { authStore.showMessage(e.message || 'Failed to update name', 'error') } | ||||
|   finally { saving.value = false } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .view-lede { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
|   font-size: 1rem; | ||||
| } | ||||
|  | ||||
| .section-header { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 0.4rem; | ||||
| } | ||||
|  | ||||
| .section-description { | ||||
|   margin: 0; | ||||
|   color: var(--color-text-muted); | ||||
| } | ||||
|  | ||||
| .logout-button { | ||||
|   align-self: flex-start; | ||||
| } | ||||
|  | ||||
| @media (max-width: 720px) { | ||||
|   .logout-button { | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| .view-lede { margin: 0; color: var(--color-text-muted); font-size: 1rem; } | ||||
| .section-header { display: flex; flex-direction: column; gap: 0.4rem; } | ||||
| .section-description { margin: 0; color: var(--color-text-muted); } | ||||
| .empty-state { margin: 0; color: var(--color-text-muted); text-align: center; padding: 1rem 0; } | ||||
| .logout-button { align-self: flex-start; } | ||||
| .logout-row { gap: 1rem; } | ||||
| .logout-row.single { justify-content: flex-start; } | ||||
| .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } | ||||
| @media (max-width: 720px) { .logout-button { width: 100%; } } | ||||
| </style> | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| <template> | ||||
|   <div class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> | ||||
|   <div v-if="!inline" class="dialog-overlay" @keydown.esc.prevent="$emit('close')"> | ||||
|     <div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle"> | ||||
|       <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;"> | ||||
|         <h2 id="regTitle" style="margin:0; font-size:1.25rem;">📱 Device Registration Link</h2> | ||||
|       <div class="reg-header-row"> | ||||
|         <h2 id="regTitle" class="reg-title"> | ||||
|           📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span> | ||||
|         </h2> | ||||
|         <button class="icon-btn" @click="$emit('close')" aria-label="Close">❌</button> | ||||
|       </div> | ||||
|       <div class="device-link-section"> | ||||
| @@ -14,28 +16,62 @@ | ||||
|           <div v-else> | ||||
|             <em>Generating link...</em> | ||||
|           </div> | ||||
|           <p> | ||||
|             <strong>Scan and visit the URL on another device.</strong><br> | ||||
|             <small>⚠️ Expires in 24 hours and one-time use.</small> | ||||
|           <p class="reg-help"> | ||||
|             <span v-if="userName">The user should open this link on the device where they want to register.</span> | ||||
|             <span v-else>Open or scan this link on the device you wish to register to your account.</span> | ||||
|             <br><small>{{ expirationMessage }}</small> | ||||
|           </p> | ||||
|           <div v-if="expires" style="font-size:12px; margin-top:6px;">Expires: {{ new Date(expires).toLocaleString() }}</div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div style="display:flex; justify-content:flex-end; gap:.5rem; margin-top:10px;"> | ||||
|       <div class="reg-actions"> | ||||
|         <button class="btn-secondary" @click="$emit('close')">Close</button> | ||||
|         <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div v-else class="registration-inline-wrapper"> | ||||
|     <div class="registration-inline-block section-block"> | ||||
|       <div class="section-header"> | ||||
|         <h2 class="inline-heading">📱 <span v-if="userName">Registration for {{ userName }}</span><span v-else>Device Registration Link</span></h2> | ||||
|       </div> | ||||
|       <div class="section-body"> | ||||
|         <div class="device-link-section"> | ||||
|           <div class="qr-container"> | ||||
|             <a v-if="url" :href="url" @click.prevent="copy" class="qr-link"> | ||||
|               <canvas ref="qrCanvas" class="qr-code"></canvas> | ||||
|               <p>{{ displayUrl }}</p> | ||||
|             </a> | ||||
|             <div v-else> | ||||
|               <em>Generating link...</em> | ||||
|             </div> | ||||
|             <p class="reg-help"> | ||||
|               <span v-if="userName">The user should open this link on the device where they want to register.</span> | ||||
|               <span v-else>Open this link on the device you wish to connect with.</span> | ||||
|               <br><small>{{ expirationMessage }}</small> | ||||
|             </p> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="button-row" style="margin-top:1rem;"> | ||||
|           <button class="btn-primary" :disabled="!url" @click="copy">Copy Link</button> | ||||
|           <button v-if="showCloseInInline" class="btn-secondary" @click="$emit('close')">Close</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, watch, computed, nextTick } from 'vue' | ||||
| import QRCode from 'qrcode/lib/browser' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   endpoint: { type: String, required: true }, // POST endpoint returning {url, expires} | ||||
|   autoCopy: { type: Boolean, default: true } | ||||
|   endpoint: { type: String, required: true }, | ||||
|   autoCopy: { type: Boolean, default: true }, | ||||
|   userName: { type: String, default: null }, | ||||
|   inline: { type: Boolean, default: false }, | ||||
|   showCloseInInline: { type: Boolean, default: false }, | ||||
|   prefixCopyWithUserName: { type: Boolean, default: false } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['close','generated','copied']) | ||||
| @@ -46,6 +82,11 @@ const qrCanvas = ref(null) | ||||
|  | ||||
| const displayUrl = computed(() => url.value ? url.value.replace(/^[^:]+:\/\//,'') : '') | ||||
|  | ||||
| const expirationMessage = computed(() => { | ||||
|   const timeStr = formatDate(expires.value) | ||||
|   return `⚠️ Expires ${timeStr.startsWith('In ') ? timeStr.substring(3) : timeStr} and can only be used once.` | ||||
| }) | ||||
|  | ||||
| async function fetchLink() { | ||||
|   try { | ||||
|     const res = await fetch(props.endpoint, { method: 'POST' }) | ||||
| @@ -73,15 +114,35 @@ async function drawQR() { | ||||
|  | ||||
| async function copy() { | ||||
|   if (!url.value) return | ||||
|   try { await navigator.clipboard.writeText(url.value); emit('copied', url.value); emit('close') } catch (_) { /* ignore */ } | ||||
|   let text = url.value | ||||
|   if (props.prefixCopyWithUserName && props.userName) { | ||||
|     text = `${props.userName} ${text}` | ||||
|   } | ||||
|   try { | ||||
|     await navigator.clipboard.writeText(text) | ||||
|     emit('copied', text) | ||||
|     if (!props.inline) emit('close') | ||||
|   } catch (_) { | ||||
|     /* ignore */ | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(fetchLink) | ||||
| watch(url, () => drawQR(), { flush: 'post' }) | ||||
|  | ||||
| </script> | ||||
| <style scoped> | ||||
| .icon-btn { background:none; border:none; cursor:pointer; font-size:1rem; opacity:.6; } | ||||
| .icon-btn:hover { opacity:1; } | ||||
| /* Minimal extra styling; main look comes from global styles */ | ||||
| .qr-link { text-decoration:none; color:inherit; } | ||||
| .reg-header-row { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.75rem; } | ||||
| .reg-title { margin:0; font-size:1.25rem; font-weight:600; } | ||||
| .device-dialog { background: var(--color-surface); padding: 1.25rem 1.25rem 1rem; border-radius: var(--radius-md); max-width:480px; width:100%; box-shadow:0 6px 28px rgba(0,0,0,.25); } | ||||
| .qr-container { display:flex; flex-direction:column; align-items:center; gap:.5rem; } | ||||
| .qr-code { display:block; } | ||||
| .reg-help { margin-top:.5rem; margin-bottom:.75rem; font-size:.85rem; line-height:1.25rem; text-align:center; } | ||||
| .reg-actions { display:flex; justify-content:flex-end; gap:.5rem; margin-top:.25rem; } | ||||
| .registration-inline-block .qr-container { align-items:flex-start; } | ||||
| .registration-inline-block .reg-help { text-align:left; } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										73
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/components/SessionList.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| <template> | ||||
|   <section class="section-block" data-component="session-list-section"> | ||||
|     <div class="section-header"> | ||||
|       <h2>Active Sessions</h2> | ||||
|       <p class="section-description">{{ sectionDescription }}</p> | ||||
|     </div> | ||||
|     <div class="section-body"> | ||||
|       <div :class="['session-list']"> | ||||
|         <template v-if="Array.isArray(sessions) && sessions.length"> | ||||
|           <div | ||||
|             v-for="session in sessions" | ||||
|             :key="session.id" | ||||
|             :class="['session-item', { 'is-current': session.is_current }]" | ||||
|           > | ||||
|             <div class="item-top"> | ||||
|               <div class="item-icon"> | ||||
|                 <span class="session-emoji">🌐</span> | ||||
|               </div> | ||||
|               <h4 class="item-title">{{ sessionHostLabel(session) }}</h4> | ||||
|               <div class="item-actions"> | ||||
|                 <span v-if="session.is_current" class="badge badge-current">Current</span> | ||||
|                 <span v-else-if="session.is_current_host" class="badge">This host</span> | ||||
|                 <button | ||||
|                   v-if="allowTerminate" | ||||
|                   @click="$emit('terminate', session)" | ||||
|                   class="btn-card-delete" | ||||
|                   :disabled="isTerminating(session.id)" | ||||
|                   :title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'" | ||||
|                 >🗑️</button> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="item-details"> | ||||
|               <div class="session-dates"> | ||||
|                 <span class="date-label">Last used:</span> | ||||
|                 <span class="date-value">{{ formatDate(session.last_renewed) }}</span> | ||||
|                 <span class="session-meta-info">{{ session.user_agent }} {{ session.ip }}</span> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </template> | ||||
|         <div v-else class="empty-state"><p>{{ emptyMessage }}</p></div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { } from 'vue' | ||||
| import { formatDate } from '@/utils/helpers' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   sessions: { type: Array, default: () => [] }, | ||||
|   allowTerminate: { type: Boolean, default: true }, | ||||
|   emptyMessage: { type: String, default: 'You currently have no other active sessions.' }, | ||||
|   sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." }, | ||||
|   terminatingSessions: { type: Object, default: () => ({}) } | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['terminate']) | ||||
|  | ||||
| const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId] | ||||
|  | ||||
| const sessionHostLabel = (session) => { | ||||
|   if (!session || !session.host) return 'Unbound host' | ||||
|   return session.host | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .session-meta-info { | ||||
|   grid-column: span 2; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										138
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								frontend/src/host/HostApp.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| <template> | ||||
|   <div class="app-shell"> | ||||
|     <StatusMessage /> | ||||
|     <main class="view-root host-view"> | ||||
|       <div class="view-content"> | ||||
|         <header class="view-header"> | ||||
|           <h1>{{ headingTitle }}</h1> | ||||
|           <p class="view-lede">{{ subheading }}</p> | ||||
|         </header> | ||||
|  | ||||
|         <section class="section-block"> | ||||
|           <div class="section-body"> | ||||
|             <UserBasicInfo | ||||
|               v-if="user" | ||||
|               :name="user.user_name" | ||||
|               :visits="user.visits || 0" | ||||
|               :created-at="user.created_at" | ||||
|               :last-seen="user.last_seen" | ||||
|               :org-display-name="orgDisplayName" | ||||
|               :role-name="roleDisplayName" | ||||
|               :can-edit="false" | ||||
|             /> | ||||
|             <p v-else class="empty-state"> | ||||
|               {{ initializing ? 'Loading your account…' : 'No active session found.' }} | ||||
|             </p> | ||||
|           </div> | ||||
|         </section> | ||||
|  | ||||
|         <section class="section-block"> | ||||
|           <div class="section-body host-actions"> | ||||
|             <div class="button-row"> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 class="btn-secondary" | ||||
|                 @click="history.back()" | ||||
|               > | ||||
|                 Back | ||||
|               </button> | ||||
|               <button | ||||
|                 type="button" | ||||
|                 class="btn-danger" | ||||
|                 :disabled="authStore.isLoading" | ||||
|                 @click="logout" | ||||
|               > | ||||
|                 {{ authStore.isLoading ? 'Signing out…' : 'Logout' }} | ||||
|               </button> | ||||
|               <button | ||||
|                 v-if="authSiteUrl" | ||||
|                 type="button" | ||||
|                 class="btn-primary" | ||||
|                 :disabled="authStore.isLoading" | ||||
|                 @click="goToAuthSite" | ||||
|               > | ||||
|                 Full Profile | ||||
|               </button> | ||||
|             </div> | ||||
|             <p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p> | ||||
|           </div> | ||||
|         </section> | ||||
|       </div> | ||||
|     </main> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, onMounted, ref } from 'vue' | ||||
| import StatusMessage from '@/components/StatusMessage.vue' | ||||
| import UserBasicInfo from '@/components/UserBasicInfo.vue' | ||||
| import { useAuthStore } from '@/stores/auth' | ||||
|  | ||||
| const authStore = useAuthStore() | ||||
| const initializing = ref(true) | ||||
| const currentHost = window.location.host | ||||
|  | ||||
| const user = computed(() => authStore.userInfo?.user || null) | ||||
| const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '') | ||||
| const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '') | ||||
|  | ||||
| const headingTitle = computed(() => { | ||||
|   const service = authStore.settings?.rp_name | ||||
|   return service ? `${service} account` : 'Account overview' | ||||
| }) | ||||
|  | ||||
| const subheading = computed(() => { | ||||
|   const service = authStore.settings?.rp_name || 'this service' | ||||
|   return `You're signed in to ${currentHost}.` | ||||
| }) | ||||
|  | ||||
| const authSiteHost = computed(() => authStore.settings?.auth_host || '') | ||||
| const authSiteUrl = computed(() => { | ||||
|   const host = authSiteHost.value | ||||
|   if (!host) return '' | ||||
|   let path = authStore.settings?.ui_base_path ?? '/auth/' | ||||
|   if (!path.startsWith('/')) path = `/${path}` | ||||
|   if (!path.endsWith('/')) path = `${path}/` | ||||
|   const protocol = window.location.protocol || 'https:' | ||||
|   return `${protocol}//${host}${path}` | ||||
| }) | ||||
|  | ||||
| const goToAuthSite = () => { | ||||
|   if (!authSiteUrl.value) return | ||||
|   window.location.href = authSiteUrl.value | ||||
| } | ||||
|  | ||||
| const logout = async () => { | ||||
|   await authStore.logout() | ||||
| } | ||||
|  | ||||
| onMounted(async () => { | ||||
|   try { | ||||
|     await authStore.loadSettings() | ||||
|     const service = authStore.settings?.rp_name | ||||
|     if (service) document.title = `${service} · Account summary` | ||||
|     await authStore.loadUserInfo() | ||||
|   } catch (error) { | ||||
|     const message = error instanceof Error ? error.message : 'Unable to load session details' | ||||
|     authStore.showMessage(message, 'error', 4000) | ||||
|   } finally { | ||||
|     initializing.value = false | ||||
|   } | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .host-view { padding: 3rem 1.5rem 4rem; } | ||||
| .host-actions { display: flex; flex-direction: column; gap: 0.75rem; } | ||||
| .host-actions .button-row { gap: 0.75rem; flex-wrap: wrap; } | ||||
| .host-actions .button-row button { flex: 0 0 auto; } | ||||
| .note { margin: 0; color: var(--color-text-muted); } | ||||
| .link { color: var(--color-accent); text-decoration: none; } | ||||
| .link:hover { text-decoration: underline; } | ||||
| .view-hint { margin-top: 0.5rem; color: var(--color-text-muted); } | ||||
| .empty-state { margin: 0; color: var(--color-text-muted); } | ||||
| @media (max-width: 600px) { | ||||
|   .host-actions .button-row { flex-direction: column; } | ||||
|   .host-actions .button-row button { width: 100%; } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										11
									
								
								frontend/src/host/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/host/main.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| import '@/assets/style.css' | ||||
|  | ||||
| import { createApp } from 'vue' | ||||
| import { createPinia } from 'pinia' | ||||
| import HostApp from './HostApp.vue' | ||||
|  | ||||
| const app = createApp(HostApp) | ||||
|  | ||||
| app.use(createPinia()) | ||||
|  | ||||
| app.mount('#app') | ||||
| @@ -10,7 +10,7 @@ | ||||
|       <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> | ||||
|             <h1>🔑 Registration</h1> | ||||
|             <p class="view-lede"> | ||||
|               {{ subtitleMessage }} | ||||
|             </p> | ||||
| @@ -38,13 +38,11 @@ | ||||
|                 <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" | ||||
| @@ -63,6 +61,7 @@ | ||||
| <script setup> | ||||
| import { computed, onMounted, reactive, ref } from 'vue' | ||||
| import passkey from '@/utils/passkey' | ||||
| import { getSettings, uiBasePath } from '@/utils/settings' | ||||
|  | ||||
| const status = reactive({ | ||||
|   show: false, | ||||
| @@ -80,18 +79,13 @@ 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'}.` | ||||
|   return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.` | ||||
| }) | ||||
|  | ||||
| const uiBasePath = computed(() => { | ||||
|   const base = settings.value?.ui_base_path || '/auth/' | ||||
|   if (base === '/') return '/' | ||||
|   return base.endsWith('/') ? base : `${base}/` | ||||
| }) | ||||
| const basePath = computed(() => uiBasePath()) | ||||
|  | ||||
| const canRegister = computed(() => !!(token.value && userInfo.value)) | ||||
|  | ||||
| @@ -109,13 +103,9 @@ function showMessage(message, type = 'info', duration = 3000) { | ||||
|  | ||||
| async function fetchSettings() { | ||||
|   try { | ||||
|     const res = await fetch('/auth/api/settings') | ||||
|     if (!res.ok) return | ||||
|     const data = await res.json() | ||||
|     const data = await getSettings() | ||||
|     settings.value = data | ||||
|     if (data?.rp_name) { | ||||
|       document.title = `${data.rp_name} · Passkey Setup` | ||||
|     } | ||||
|     if (data?.rp_name) document.title = `${data.rp_name} · Passkey Setup` | ||||
|   } catch (error) { | ||||
|     console.warn('Unable to load settings', error) | ||||
|   } | ||||
| @@ -135,6 +125,7 @@ async function fetchUserInfo() { | ||||
|       return | ||||
|     } | ||||
|     userInfo.value = await res.json() | ||||
|     displayName.value = userInfo.value?.user?.user_name || '' | ||||
|   } catch (error) { | ||||
|     console.error('Failed to load user info', error) | ||||
|     const message = 'We could not load your reset details. Try refreshing the page.' | ||||
|   | ||||
| @@ -8,29 +8,22 @@ | ||||
|  | ||||
|     <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> | ||||
|         <div v-if="!initializing" class="surface surface--tight"> | ||||
|           <header class="view-header center"> | ||||
|             <h1>{{ headingTitle }}</h1> | ||||
|             <p v-if="isAuthenticated" class="user-line">👤 {{ userDisplayName }}</p> | ||||
|             <p class="view-lede">{{ headerMessage }}</p> | ||||
|           </header> | ||||
|  | ||||
|           <section class="section-block" v-if="initializing"> | ||||
|           <section class="section-block"> | ||||
|             <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;"> | ||||
|               <div class="button-row center"> | ||||
|                 <button class="btn-secondary" :disabled="loading" @click="backNav">Back</button> | ||||
|                 <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 | ||||
|                   {{ loading ? 'Signing in…' : 'Login' }} | ||||
|                 </button> | ||||
|                 <button v-if="isAuthenticated" class="btn-danger" :disabled="loading" @click="logoutUser">Logout</button> | ||||
|                 <button v-if="isAuthenticated" class="btn-primary" :disabled="loading" @click="returnHome">Profile</button> | ||||
|               </div> | ||||
|             </div> | ||||
|           </section> | ||||
| @@ -43,64 +36,44 @@ | ||||
| <script setup> | ||||
| import { computed, onMounted, reactive, ref } from 'vue' | ||||
| import passkey from '@/utils/passkey' | ||||
| import { getSettings, uiBasePath } from '@/utils/settings' | ||||
|  | ||||
| const status = reactive({ | ||||
|   show: false, | ||||
|   message: '', | ||||
|   type: 'info' | ||||
| }) | ||||
|  | ||||
| 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 basePath = computed(() => uiBasePath()) | ||||
|  | ||||
| const headingTitle = computed(() => { | ||||
|   if (!isAuthenticated.value) return `🔐 ${settings.value?.rp_name || location.origin}` | ||||
|   return '🚫 Forbidden' | ||||
| }) | ||||
|  | ||||
| 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.' | ||||
|   if (!isAuthenticated.value) return 'Please sign in to access this page.' | ||||
|   return 'You lack the permissions required to access this page.' | ||||
| }) | ||||
|  | ||||
| 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.' | ||||
| }) | ||||
| const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User') | ||||
|  | ||||
| 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) | ||||
|   } | ||||
|   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() | ||||
|     const data = await getSettings() | ||||
|     settings.value = data | ||||
|     if (data?.rp_name) { | ||||
|       document.title = `${data.rp_name} · Access Restricted` | ||||
|     } | ||||
|     if (data?.rp_name) document.title = isAuthenticated.value ? `${data.rp_name} · Forbidden` : `${data.rp_name} · Sign In` | ||||
|   } catch (error) { | ||||
|     console.warn('Unable to load settings', error) | ||||
|   } | ||||
| @@ -109,15 +82,18 @@ async function fetchSettings() { | ||||
| async function fetchUserInfo() { | ||||
|   try { | ||||
|     const res = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||
|     console.log("fetchUserInfo response:", res); // Debug log | ||||
|     if (!res.ok) { | ||||
|       const payload = await safeParseJson(res) | ||||
|       fallbackDetail.value = payload?.detail || 'Please sign in to continue.' | ||||
|       showMessage(payload.detail || 'Unable to load user session info.', 'error', 2000) | ||||
|       return | ||||
|     } | ||||
|     userInfo.value = await res.json() | ||||
|     // If the user is authenticated but still here, they lack permissions. | ||||
|     if (isAuthenticated.value) showMessage('Permission Denied', 'error', 2000) | ||||
|   } catch (error) { | ||||
|     console.error('Failed to load user info', error) | ||||
|     fallbackDetail.value = 'We were unable to verify your session. Try again shortly.' | ||||
|     showMessage('Could not contact the authentication server', 'error', 2000) | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -125,83 +101,76 @@ 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) { | ||||
|   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) { | ||||
|   try { await setSessionCookie(result.session_token) } catch (error) { | ||||
|     loading.value = false | ||||
|     const message = error?.message || 'Failed to establish session' | ||||
|     showMessage(message, 'error', 4000) | ||||
|     return | ||||
|   } | ||||
|   location.reload() | ||||
| } | ||||
|  | ||||
|   showMessage('Signed in successfully!', 'success', 2000) | ||||
|   setTimeout(() => { | ||||
|     loading.value = false | ||||
|     window.location.reload() | ||||
|   }, 800) | ||||
| async function logoutUser() { | ||||
|   if (loading.value) return | ||||
|   loading.value = true | ||||
|   try { await fetch('/auth/api/logout', { method: 'POST' }) } catch (_) { /* ignore */ } | ||||
|   finally { loading.value = false; window.location.reload() } | ||||
| } | ||||
|  | ||||
| async function setSessionCookie(sessionToken) { | ||||
|   const response = await fetch('/auth/api/set-session', { | ||||
|     method: 'POST', | ||||
|     headers: { | ||||
|       Authorization: `Bearer ${sessionToken}` | ||||
|     } | ||||
|     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) | ||||
|   } | ||||
|   if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.') | ||||
|   return payload | ||||
| } | ||||
|  | ||||
| function returnHome() { | ||||
|   const target = uiBasePath.value || '/auth/' | ||||
|   if (window.location.pathname !== target) { | ||||
|     history.replaceState(null, '', target) | ||||
|   } | ||||
|   const target = basePath.value || '/auth/' | ||||
|   if (window.location.pathname !== target) history.replaceState(null, '', target) | ||||
|   window.location.href = target | ||||
| } | ||||
|  | ||||
| async function safeParseJson(response) { | ||||
| function backNav() { | ||||
|   try { | ||||
|     return await response.json() | ||||
|   } catch (error) { | ||||
|     return null | ||||
|   } | ||||
|     if (history.length > 1) { | ||||
|       history.back() | ||||
|       return | ||||
|     } | ||||
|   } catch (_) { /* ignore */ } | ||||
|   returnHome() | ||||
| } | ||||
|  | ||||
| async function safeParseJson(response) { try { return await response.json() } catch (_) { 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 { | ||||
| .button-row.center { display: flex; justify-content: center; gap: 0.75rem; } | ||||
| .user-line { margin: 0.5rem 0 0; font-weight: 500; color: var(--color-text); } | ||||
| /* Vertically center the restricted "dialog" surface in the viewport */ | ||||
| main.view-root { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; } | ||||
| main.view-root .view-content { width: 100%; } | ||||
| .surface.surface--tight { | ||||
|   max-width: 520px; | ||||
|   margin: 0 auto; | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|   gap: 0.75rem; | ||||
|   flex-direction: column; | ||||
|   gap: 1.75rem; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,13 +1,16 @@ | ||||
| import { defineStore } from 'pinia' | ||||
| import { register, authenticate } from '@/utils/passkey' | ||||
| import { getSettings } from '@/utils/settings' | ||||
|  | ||||
| export const useAuthStore = defineStore('auth', { | ||||
|   state: () => ({ | ||||
|     // Auth State | ||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} | ||||
|     settings: null, // Server provided settings (/auth/settings) | ||||
|     userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} | ||||
|     isLoading: false, | ||||
|  | ||||
|     // Settings | ||||
|     settings: null, | ||||
|  | ||||
|     // UI State | ||||
|     currentView: 'login', | ||||
|     status: { | ||||
| @@ -17,15 +20,6 @@ export const useAuthStore = defineStore('auth', { | ||||
|     }, | ||||
|   }), | ||||
|   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) { | ||||
| @@ -43,15 +37,6 @@ 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', | ||||
| @@ -91,8 +76,10 @@ export const useAuthStore = defineStore('auth', { | ||||
|     }, | ||||
|     selectView() { | ||||
|       if (!this.userInfo) this.currentView = 'login' | ||||
|       else if (this.userInfo.authenticated) this.currentView = 'profile' | ||||
|       else this.currentView = 'login' | ||||
|       else this.currentView = 'profile' | ||||
|     }, | ||||
|     async loadSettings() { | ||||
|       this.settings = await getSettings() | ||||
|     }, | ||||
|     async loadUserInfo() { | ||||
|       const response = await fetch('/auth/api/user-info', { method: 'POST' }) | ||||
| @@ -114,29 +101,51 @@ export const useAuthStore = defineStore('auth', { | ||||
|       this.userInfo = result | ||||
|       console.log('User info loaded:', result) | ||||
|     }, | ||||
|     async loadSettings() { | ||||
|       try { | ||||
|   const res = await fetch('/auth/api/settings') | ||||
|         if (!res.ok) return | ||||
|         const data = await res.json() | ||||
|         this.settings = data | ||||
|         if (data?.rp_name) { | ||||
|           document.title = data.rp_name | ||||
|         } | ||||
|       } catch (_) { | ||||
|         // ignore | ||||
|       } | ||||
|     }, | ||||
|     async deleteCredential(uuid) { | ||||
|   const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'}) | ||||
|   const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'}) | ||||
|       const result = await response.json() | ||||
|       if (result.detail) throw new Error(`Server: ${result.detail}`) | ||||
|  | ||||
|       await this.loadUserInfo() | ||||
|     }, | ||||
|     async terminateSession(sessionId) { | ||||
|       try { | ||||
|         const res = await fetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' }) | ||||
|         let payload = null | ||||
|         try { | ||||
|           payload = await res.json() | ||||
|         } catch (_) { | ||||
|           // ignore JSON parse errors | ||||
|         } | ||||
|         if (!res.ok || payload?.detail) { | ||||
|           const message = payload?.detail || 'Failed to terminate session' | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         if (payload?.current_session_terminated) { | ||||
|           sessionStorage.clear() | ||||
|           location.reload() | ||||
|           return | ||||
|         } | ||||
|         await this.loadUserInfo() | ||||
|         this.showMessage('Session terminated', 'success', 2500) | ||||
|       } catch (error) { | ||||
|         console.error('Terminate session error:', error) | ||||
|         throw error | ||||
|       } | ||||
|     }, | ||||
|     async logout() { | ||||
|       try { | ||||
|         await fetch('/auth/api/logout', {method: 'POST'}) | ||||
|         const res = await fetch('/auth/api/logout', {method: 'POST'}) | ||||
|         if (!res.ok) { | ||||
|           let message = 'Logout failed' | ||||
|           try { | ||||
|             const data = await res.json() | ||||
|             if (data?.detail) message = data.detail | ||||
|           } catch (_) { | ||||
|             // ignore JSON parse errors | ||||
|           } | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         sessionStorage.clear() | ||||
|         location.reload() | ||||
|       } catch (error) { | ||||
| @@ -144,5 +153,25 @@ export const useAuthStore = defineStore('auth', { | ||||
|         this.showMessage(error.message, 'error') | ||||
|       } | ||||
|     }, | ||||
|     async logoutEverywhere() { | ||||
|       try { | ||||
|         const res = await fetch('/auth/api/user/logout-all', {method: 'POST'}) | ||||
|         if (!res.ok) { | ||||
|           let message = 'Logout failed' | ||||
|           try { | ||||
|             const data = await res.json() | ||||
|             if (data?.detail) message = data.detail | ||||
|           } catch (_) { | ||||
|             // ignore JSON parse errors | ||||
|           } | ||||
|           throw new Error(message) | ||||
|         } | ||||
|         sessionStorage.clear() | ||||
|         location.reload() | ||||
|       } catch (error) { | ||||
|         console.error('Logout-all error:', error) | ||||
|         this.showMessage(error.message, 'error') | ||||
|       } | ||||
|     }, | ||||
|   } | ||||
| }) | ||||
|   | ||||
| @@ -5,16 +5,18 @@ export function formatDate(dateString) { | ||||
|  | ||||
|   const date = new Date(dateString) | ||||
|   const now = new Date() | ||||
|   const diffMs = now - date | ||||
|   const diffMinutes = Math.floor(diffMs / (1000 * 60)) | ||||
|   const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) | ||||
|   const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) | ||||
|   const diffMs = date - now  // Changed to date - now for future/past | ||||
|   const isFuture = diffMs > 0 | ||||
|   const absDiffMs = Math.abs(diffMs) | ||||
|   const diffMinutes = Math.round(absDiffMs / (1000 * 60)) | ||||
|   const diffHours = Math.round(absDiffMs / (1000 * 60 * 60)) | ||||
|   const diffDays = Math.round(absDiffMs / (1000 * 60 * 60 * 24)) | ||||
|  | ||||
|   if (diffMs < 0 || diffDays > 7) return date.toLocaleDateString() | ||||
|   if (diffMinutes === 0) return 'Just now' | ||||
|   if (diffMinutes < 60) return diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` | ||||
|   if (diffHours < 24) return diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` | ||||
|   return diffDays === 1 ? 'a day ago' : `${diffDays} days ago` | ||||
|   if (absDiffMs < 1000 * 60) return 'Now' | ||||
|   if (diffMinutes <= 60) return isFuture ? `In ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}` : diffMinutes === 1 ? 'a minute ago' : `${diffMinutes} minutes ago` | ||||
|   if (diffHours <= 24) return isFuture ? `In ${diffHours} hour${diffHours === 1 ? '' : 's'}` : diffHours === 1 ? 'an hour ago' : `${diffHours} hours ago` | ||||
|   if (diffDays <= 14) return isFuture ? `In ${diffDays} day${diffDays === 1 ? '' : 's'}` : diffDays === 1 ? 'a day ago' : `${diffDays} days ago` | ||||
|   return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) | ||||
| } | ||||
|  | ||||
| export function getCookie(name) { | ||||
|   | ||||
| @@ -1,13 +1,21 @@ | ||||
| import { startRegistration, startAuthentication } from '@simplewebauthn/browser' | ||||
| import aWebSocket from '@/utils/awaitable-websocket' | ||||
| import { getSettings } from '@/utils/settings' | ||||
|  | ||||
| // Generic path normalizer: if an auth_host is configured and differs from current | ||||
| // host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is. | ||||
| async function makeUrl(path) { | ||||
|   const s = await getSettings() | ||||
|   const h = s?.auth_host | ||||
|   return h && location.host !== h ? `//${h}${path}` : path | ||||
| } | ||||
|  | ||||
| export async function register(resetToken = null, displayName = null) { | ||||
|   let params = [] | ||||
|   if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) | ||||
|   if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) | ||||
|   const qs = params.length ? `?${params.join('&')}` : '' | ||||
|   const url = `/auth/ws/register${qs}` | ||||
|   const ws = await aWebSocket(url) | ||||
|   const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`)) | ||||
|   try { | ||||
|     const optionsJSON = await ws.receive_json() | ||||
|     const registrationResponse = await startRegistration({ optionsJSON }) | ||||
| @@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) { | ||||
| } | ||||
|  | ||||
| export async function authenticate() { | ||||
|   const ws = await aWebSocket('/auth/ws/authenticate') | ||||
|   const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) | ||||
|   try { | ||||
|     const optionsJSON = await ws.receive_json() | ||||
|     const authResponse = await startAuthentication({ optionsJSON }) | ||||
|   | ||||
							
								
								
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/utils/settings.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| let _settingsPromise = null | ||||
| let _settings = null | ||||
|  | ||||
| export function getSettingsCached() { return _settings } | ||||
|  | ||||
| export async function getSettings() { | ||||
|   if (_settings) return _settings | ||||
|   if (_settingsPromise) return _settingsPromise | ||||
|   _settingsPromise = fetch('/auth/api/settings') | ||||
|     .then(r => (r.ok ? r.json() : {})) | ||||
|     .then(obj => { _settings = obj || {}; return _settings }) | ||||
|     .catch(() => { _settings = {}; return _settings }) | ||||
|   return _settingsPromise | ||||
| } | ||||
|  | ||||
| export function uiBasePath() { | ||||
|   const base = _settings?.ui_base_path || '/auth/' | ||||
|   if (base === '/') return '/' | ||||
|   return base.endsWith('/') ? base : base + '/' | ||||
| } | ||||
|  | ||||
| export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' } | ||||
|  | ||||
| export function makeUiHref(suffix = '') { | ||||
|   const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix | ||||
|   if (!trimmed) return uiBasePath() | ||||
|   if (uiBasePath() === '/') return '/' + trimmed | ||||
|   return uiBasePath() + trimmed | ||||
| } | ||||
| @@ -33,6 +33,8 @@ export default defineConfig(({ command, mode }) => ({ | ||||
|           // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. | ||||
|           // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. | ||||
|           if (url === '/auth/' || url === '/auth') return '/' | ||||
|           if (url === '/auth/host' || url === '/auth/host/') return '/host/index.html' | ||||
|           if (url === '/host' || url === '/host/') return '/host/index.html' | ||||
|           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' | ||||
| @@ -53,7 +55,8 @@ export default defineConfig(({ command, mode }) => ({ | ||||
|         index: resolve(__dirname, 'index.html'), | ||||
|         admin: resolve(__dirname, 'admin/index.html'), | ||||
|         reset: resolve(__dirname, 'reset/index.html'), | ||||
|         restricted: resolve(__dirname, 'restricted/index.html') | ||||
|         restricted: resolve(__dirname, 'restricted/index.html'), | ||||
|         host: resolve(__dirname, 'host/index.html') | ||||
|       }, | ||||
|       output: {} | ||||
|     } | ||||
|   | ||||
| @@ -8,61 +8,115 @@ independent of any web framework: | ||||
| - Credential management | ||||
| """ | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| from datetime import datetime, timezone | ||||
| from uuid import UUID | ||||
|  | ||||
| from .db import Session | ||||
| from .globals import db | ||||
| from .config import SESSION_LIFETIME | ||||
| from .db import ResetToken, Session | ||||
| from .globals import db, passkey | ||||
| from .util import hostutil | ||||
| from .util.tokens import create_token, reset_key, session_key | ||||
|  | ||||
| EXPIRES = timedelta(hours=24) | ||||
| EXPIRES = SESSION_LIFETIME | ||||
|  | ||||
|  | ||||
| def expires() -> datetime: | ||||
|     return datetime.now() + EXPIRES | ||||
|     return datetime.now(timezone.utc) + EXPIRES | ||||
|  | ||||
|  | ||||
| async def create_session(user_uuid: UUID, credential_uuid: UUID, info: dict) -> str: | ||||
| def reset_expires() -> datetime: | ||||
|     from .config import RESET_LIFETIME | ||||
|  | ||||
|     return datetime.now(timezone.utc) + RESET_LIFETIME | ||||
|  | ||||
|  | ||||
| def session_expiry(session: Session) -> datetime: | ||||
|     """Calculate the expiration timestamp for a session (UTC aware).""" | ||||
|     # After migration all renewed timestamps are timezone-aware UTC | ||||
|     return session.renewed + EXPIRES | ||||
|  | ||||
|  | ||||
| async def create_session( | ||||
|     user_uuid: UUID, | ||||
|     credential_uuid: UUID, | ||||
|     *, | ||||
|     host: str, | ||||
|     ip: str, | ||||
|     user_agent: str, | ||||
| ) -> str: | ||||
|     """Create a new session and return a session token.""" | ||||
|     normalized_host = hostutil.normalize_host(host) | ||||
|     if not normalized_host: | ||||
|         raise ValueError("Host required for session creation") | ||||
|     hostname = normalized_host.split(":")[0]  # Domain names only, IPs aren't supported | ||||
|     rp_id = passkey.instance.rp_id | ||||
|     if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): | ||||
|         raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") | ||||
|     token = create_token() | ||||
|     now = datetime.now(timezone.utc) | ||||
|     await db.instance.create_session( | ||||
|         user_uuid=user_uuid, | ||||
|         credential_uuid=credential_uuid, | ||||
|         key=session_key(token), | ||||
|         expires=datetime.now() + EXPIRES, | ||||
|         info=info, | ||||
|         host=normalized_host, | ||||
|         ip=ip, | ||||
|         user_agent=user_agent, | ||||
|         renewed=now, | ||||
|     ) | ||||
|     return token | ||||
|  | ||||
|  | ||||
| async def get_reset(token: str) -> Session: | ||||
| async def get_reset(token: str) -> ResetToken: | ||||
|     """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" | ||||
|     session = await db.instance.get_session(reset_key(token)) | ||||
|     if not session: | ||||
|     record = await db.instance.get_reset_token(reset_key(token)) | ||||
|     if not record: | ||||
|         raise ValueError("Invalid or expired session token") | ||||
|     return session | ||||
|     if record.expiry < datetime.now(timezone.utc): | ||||
|         await db.instance.delete_reset_token(record.key) | ||||
|         raise ValueError("Invalid or expired session token") | ||||
|     return record | ||||
|  | ||||
|  | ||||
| async def get_session(token: str) -> Session: | ||||
| async def get_session(token: str, host: str | None = None) -> Session: | ||||
|     """Validate a session token and return session data if valid.""" | ||||
|     session = await db.instance.get_session(session_key(token)) | ||||
|     if not session: | ||||
|         raise ValueError("Invalid or expired session token") | ||||
|     if session_expiry(session) < datetime.now(timezone.utc): | ||||
|         await db.instance.delete_session(session.key) | ||||
|         raise ValueError("Invalid or expired session token") | ||||
|     if host is not None: | ||||
|         normalized_host = hostutil.normalize_host(host) | ||||
|         if not normalized_host: | ||||
|             raise ValueError("Invalid host") | ||||
|         current = session.host | ||||
|         if current is None: | ||||
|             # First time binding: store exact host:port (or IPv6 form) now. | ||||
|             await db.instance.set_session_host(session.key, normalized_host) | ||||
|             session.host = normalized_host | ||||
|         elif current == normalized_host: | ||||
|             pass  # exact match ok | ||||
|         else: | ||||
|             raise ValueError("Invalid or expired session token") | ||||
|     return session | ||||
|  | ||||
|  | ||||
| async def refresh_session_token(token: str): | ||||
| async def refresh_session_token(token: str, *, ip: str, user_agent: str): | ||||
|     """Refresh a session extending its expiry.""" | ||||
|     # Get the current session | ||||
|     s = await db.instance.update_session( | ||||
|         session_key(token), datetime.now() + EXPIRES, {} | ||||
|     session_record = await db.instance.get_session(session_key(token)) | ||||
|     if not session_record: | ||||
|         raise ValueError("Session not found or expired") | ||||
|     updated = await db.instance.update_session( | ||||
|         session_key(token), | ||||
|         ip=ip, | ||||
|         user_agent=user_agent, | ||||
|         renewed=datetime.now(timezone.utc), | ||||
|     ) | ||||
|  | ||||
|     if not s: | ||||
|     if not updated: | ||||
|         raise ValueError("Session not found or expired") | ||||
|  | ||||
|  | ||||
| async def delete_credential(credential_uuid: UUID, auth: str): | ||||
| async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): | ||||
|     """Delete a specific credential for the current user.""" | ||||
|     s = await get_session(auth) | ||||
|     s = await get_session(auth, host=host) | ||||
|     await db.instance.delete_credential(credential_uuid, s.user_uuid) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ generating a reset link for initial admin setup. | ||||
|  | ||||
| import asyncio | ||||
| import logging | ||||
| from datetime import datetime | ||||
| from datetime import datetime, timezone | ||||
|  | ||||
| import uuid7 | ||||
|  | ||||
| @@ -41,11 +41,12 @@ ADMIN_RESET_MESSAGE = """\ | ||||
| async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str: | ||||
|     """Create an admin reset link and log it with the provided message.""" | ||||
|     token = passphrase.generate() | ||||
|     await globals.db.instance.create_session( | ||||
|     expiry = authsession.reset_expires() | ||||
|     await globals.db.instance.create_reset_token( | ||||
|         user_uuid=user_uuid, | ||||
|         key=tokens.reset_key(token), | ||||
|         expires=authsession.expires(), | ||||
|         info={"type": session_type}, | ||||
|         expiry=expiry, | ||||
|         token_type=session_type, | ||||
|     ) | ||||
|     reset_link = hostutil.reset_link_url(token) | ||||
|     logger.info(ADMIN_RESET_MESSAGE, message, reset_link) | ||||
| @@ -90,7 +91,7 @@ async def bootstrap_system( | ||||
|         uuid=uuid7.create(), | ||||
|         display_name=user_name or "Admin", | ||||
|         role_uuid=role.uuid, | ||||
|         created_at=datetime.now(), | ||||
|         created_at=datetime.now(timezone.utc), | ||||
|         visits=0, | ||||
|     ) | ||||
|     await globals.db.instance.create_user(user) | ||||
|   | ||||
							
								
								
									
										7
									
								
								passkey/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								passkey/config.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from datetime import timedelta | ||||
|  | ||||
| # Shared configuration constants for session management. | ||||
| SESSION_LIFETIME = timedelta(hours=24) | ||||
|  | ||||
| # Lifetime for reset links created by admins | ||||
| RESET_LIFETIME = timedelta(days=14) | ||||
| @@ -63,9 +63,27 @@ class Credential: | ||||
| class Session: | ||||
|     key: bytes | ||||
|     user_uuid: UUID | ||||
|     expires: datetime | ||||
|     info: dict | ||||
|     credential_uuid: UUID | None = None | ||||
|     credential_uuid: UUID | ||||
|     host: str | ||||
|     ip: str | ||||
|     user_agent: str | ||||
|     renewed: datetime | ||||
|  | ||||
|     def metadata(self) -> dict: | ||||
|         """Return session metadata for backwards compatibility.""" | ||||
|         return { | ||||
|             "ip": self.ip, | ||||
|             "user_agent": self.user_agent, | ||||
|             "renewed": self.renewed.isoformat(), | ||||
|         } | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class ResetToken: | ||||
|     key: bytes | ||||
|     user_uuid: UUID | ||||
|     expiry: datetime | ||||
|     token_type: str | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @@ -146,9 +164,11 @@ class DatabaseInterface(ABC): | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         key: bytes, | ||||
|         expires: datetime, | ||||
|         info: dict, | ||||
|         credential_uuid: UUID | None = None, | ||||
|         credential_uuid: UUID, | ||||
|         host: str, | ||||
|         ip: str, | ||||
|         user_agent: str, | ||||
|         renewed: datetime, | ||||
|     ) -> None: | ||||
|         """Create a new session.""" | ||||
|  | ||||
| @@ -162,14 +182,50 @@ class DatabaseInterface(ABC): | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def update_session( | ||||
|         self, key: bytes, expires: datetime, info: dict | ||||
|         self, | ||||
|         key: bytes, | ||||
|         *, | ||||
|         ip: str, | ||||
|         user_agent: str, | ||||
|         renewed: datetime, | ||||
|     ) -> Session | None: | ||||
|         """Update session expiry and info.""" | ||||
|         """Update session metadata and touch renewed timestamp.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def set_session_host(self, key: bytes, host: str) -> None: | ||||
|         """Bind a session to a specific host if not already set.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: | ||||
|         """Return all sessions for a user (including other hosts).""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def cleanup(self) -> None: | ||||
|         """Called periodically to clean up expired records.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def delete_sessions_for_user(self, user_uuid: UUID) -> None: | ||||
|         """Delete all sessions belonging to the provided user.""" | ||||
|  | ||||
|     # Reset token operations | ||||
|     @abstractmethod | ||||
|     async def create_reset_token( | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         key: bytes, | ||||
|         expiry: datetime, | ||||
|         token_type: str, | ||||
|     ) -> None: | ||||
|         """Create a reset token for a user.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def get_reset_token(self, key: bytes) -> ResetToken | None: | ||||
|         """Retrieve a reset token by key.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def delete_reset_token(self, key: bytes) -> None: | ||||
|         """Delete a reset token by key.""" | ||||
|  | ||||
|     # Organization operations | ||||
|     @abstractmethod | ||||
|     async def create_organization(self, org: Org) -> None: | ||||
| @@ -315,36 +371,41 @@ class DatabaseInterface(ABC): | ||||
|         """Create a new user and their first credential in a transaction.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def get_session_context(self, session_key: bytes) -> SessionContext | None: | ||||
|     async def get_session_context( | ||||
|         self, session_key: bytes, host: str | None = None | ||||
|     ) -> SessionContext | None: | ||||
|         """Get complete session context including user, organization, role, and permissions.""" | ||||
|  | ||||
|         # Combined atomic operations | ||||
|         @abstractmethod | ||||
|         async def create_credential_session( | ||||
|             self, | ||||
|             user_uuid: UUID, | ||||
|             credential: Credential, | ||||
|             reset_key: bytes | None, | ||||
|             session_key: bytes, | ||||
|             session_expires: datetime, | ||||
|             session_info: dict, | ||||
|             display_name: str | None = None, | ||||
|         ) -> None: | ||||
|             """Atomically add a credential and create a session. | ||||
|     # Combined atomic operations | ||||
|     @abstractmethod | ||||
|     async def create_credential_session( | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         credential: Credential, | ||||
|         reset_key: bytes | None, | ||||
|         session_key: bytes, | ||||
|         *, | ||||
|         display_name: str | None = None, | ||||
|         host: str | None = None, | ||||
|         ip: str | None = None, | ||||
|         user_agent: str | None = None, | ||||
|     ) -> None: | ||||
|         """Atomically add a credential and create a session. | ||||
|  | ||||
|             Steps (single transaction): | ||||
|                 1. Insert credential | ||||
|                 2. Optionally delete old session (e.g. reset token) if provided | ||||
|                 3. Optionally update user's display name | ||||
|                 4. Insert new session referencing the credential | ||||
|                 5. Update user's last_seen and increment visits (treat as a login) | ||||
|             """ | ||||
|         Steps (single transaction): | ||||
|             1. Insert credential | ||||
|             2. Optionally delete old reset token if provided | ||||
|             3. Optionally update user's display name | ||||
|             4. Insert new session referencing the credential | ||||
|             5. Update user's last_seen and increment visits (treat as a login) | ||||
|         """ | ||||
|  | ||||
|  | ||||
| __all__ = [ | ||||
|     "User", | ||||
|     "Credential", | ||||
|     "Session", | ||||
|     "ResetToken", | ||||
|     "SessionContext", | ||||
|     "Org", | ||||
|     "Role", | ||||
|   | ||||
| @@ -6,7 +6,7 @@ for managing users and credentials in a WebAuthn authentication system. | ||||
| """ | ||||
|  | ||||
| from contextlib import asynccontextmanager | ||||
| from datetime import datetime | ||||
| from datetime import datetime, timezone | ||||
| from uuid import UUID | ||||
|  | ||||
| from sqlalchemy import ( | ||||
| @@ -19,18 +19,21 @@ from sqlalchemy import ( | ||||
|     event, | ||||
|     insert, | ||||
|     select, | ||||
|     text, | ||||
|     update, | ||||
| ) | ||||
| from sqlalchemy.dialects.sqlite import BLOB, JSON | ||||
| from sqlalchemy.dialects.sqlite import BLOB | ||||
| from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine | ||||
| from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column | ||||
|  | ||||
| from ..config import SESSION_LIFETIME | ||||
| from ..globals import db | ||||
| from . import ( | ||||
|     Credential, | ||||
|     DatabaseInterface, | ||||
|     Org, | ||||
|     Permission, | ||||
|     ResetToken, | ||||
|     Role, | ||||
|     Session, | ||||
|     SessionContext, | ||||
| @@ -40,6 +43,14 @@ from . import ( | ||||
| DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" | ||||
|  | ||||
|  | ||||
| def _normalize_dt(value: datetime | None) -> datetime | None: | ||||
|     if value is None: | ||||
|         return None | ||||
|     if value.tzinfo is None: | ||||
|         return value.replace(tzinfo=timezone.utc) | ||||
|     return value.astimezone(timezone.utc) | ||||
|  | ||||
|  | ||||
| async def init(*args, **kwargs): | ||||
|     db.instance = DB() | ||||
|     await db.instance.init_db() | ||||
| @@ -98,8 +109,12 @@ class UserModel(Base): | ||||
|     role_uuid: Mapped[bytes] = mapped_column( | ||||
|         LargeBinary(16), ForeignKey("roles.uuid", ondelete="CASCADE"), nullable=False | ||||
|     ) | ||||
|     created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) | ||||
|     last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | ||||
|     created_at: Mapped[datetime] = mapped_column( | ||||
|         DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) | ||||
|     ) | ||||
|     last_seen: Mapped[datetime | None] = mapped_column( | ||||
|         DateTime(timezone=True), nullable=True | ||||
|     ) | ||||
|     visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) | ||||
|  | ||||
|     def as_dataclass(self) -> User: | ||||
| @@ -107,8 +122,8 @@ class UserModel(Base): | ||||
|             uuid=UUID(bytes=self.uuid), | ||||
|             display_name=self.display_name, | ||||
|             role_uuid=UUID(bytes=self.role_uuid), | ||||
|             created_at=self.created_at, | ||||
|             last_seen=self.last_seen, | ||||
|             created_at=_normalize_dt(self.created_at) or self.created_at, | ||||
|             last_seen=_normalize_dt(self.last_seen) or self.last_seen, | ||||
|             visits=self.visits, | ||||
|         ) | ||||
|  | ||||
| @@ -118,7 +133,7 @@ class UserModel(Base): | ||||
|             uuid=user.uuid.bytes, | ||||
|             display_name=user.display_name, | ||||
|             role_uuid=user.role_uuid.bytes, | ||||
|             created_at=user.created_at or datetime.now(), | ||||
|             created_at=user.created_at or datetime.now(timezone.utc), | ||||
|             last_seen=user.last_seen, | ||||
|             visits=user.visits, | ||||
|         ) | ||||
| @@ -137,9 +152,29 @@ class CredentialModel(Base): | ||||
|     aaguid: Mapped[bytes] = mapped_column(LargeBinary(16), nullable=False) | ||||
|     public_key: Mapped[bytes] = mapped_column(BLOB, nullable=False) | ||||
|     sign_count: Mapped[int] = mapped_column(Integer, nullable=False) | ||||
|     created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) | ||||
|     last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | ||||
|     last_verified: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | ||||
|     created_at: Mapped[datetime] = mapped_column( | ||||
|         DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) | ||||
|     ) | ||||
|     # Columns declared timezone-aware going forward; legacy rows may still be naive in storage | ||||
|     last_used: Mapped[datetime | None] = mapped_column( | ||||
|         DateTime(timezone=True), nullable=True | ||||
|     ) | ||||
|     last_verified: Mapped[datetime | None] = mapped_column( | ||||
|         DateTime(timezone=True), nullable=True | ||||
|     ) | ||||
|  | ||||
|     def as_dataclass(self):  # type: ignore[override] | ||||
|         return Credential( | ||||
|             uuid=UUID(bytes=self.uuid), | ||||
|             credential_id=self.credential_id, | ||||
|             user_uuid=UUID(bytes=self.user_uuid), | ||||
|             aaguid=UUID(bytes=self.aaguid), | ||||
|             public_key=self.public_key, | ||||
|             sign_count=self.sign_count, | ||||
|             created_at=_normalize_dt(self.created_at) or self.created_at, | ||||
|             last_used=_normalize_dt(self.last_used) or self.last_used, | ||||
|             last_verified=_normalize_dt(self.last_verified) or self.last_verified, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class SessionModel(Base): | ||||
| @@ -147,23 +182,31 @@ class SessionModel(Base): | ||||
|  | ||||
|     key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) | ||||
|     user_uuid: Mapped[bytes] = mapped_column( | ||||
|         LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE") | ||||
|         LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False | ||||
|     ) | ||||
|     credential_uuid: Mapped[bytes | None] = mapped_column( | ||||
|         LargeBinary(16), ForeignKey("credentials.uuid", ondelete="CASCADE") | ||||
|     credential_uuid: Mapped[bytes] = mapped_column( | ||||
|         LargeBinary(16), | ||||
|         ForeignKey("credentials.uuid", ondelete="CASCADE"), | ||||
|         nullable=False, | ||||
|     ) | ||||
|     host: Mapped[str] = mapped_column(String, nullable=False) | ||||
|     ip: Mapped[str] = mapped_column(String(64), nullable=False) | ||||
|     user_agent: Mapped[str] = mapped_column(String(512), nullable=False) | ||||
|     renewed: Mapped[datetime] = mapped_column( | ||||
|         DateTime(timezone=True), | ||||
|         default=lambda: datetime.now(timezone.utc), | ||||
|         nullable=False, | ||||
|     ) | ||||
|     expires: Mapped[datetime] = mapped_column(DateTime, nullable=False) | ||||
|     info: Mapped[dict] = mapped_column(JSON, default=dict) | ||||
|  | ||||
|     def as_dataclass(self): | ||||
|         return Session( | ||||
|             key=self.key, | ||||
|             user_uuid=UUID(bytes=self.user_uuid), | ||||
|             credential_uuid=( | ||||
|                 UUID(bytes=self.credential_uuid) if self.credential_uuid else None | ||||
|             ), | ||||
|             expires=self.expires, | ||||
|             info=self.info, | ||||
|             credential_uuid=UUID(bytes=self.credential_uuid), | ||||
|             host=self.host, | ||||
|             ip=self.ip, | ||||
|             user_agent=self.user_agent, | ||||
|             renewed=_normalize_dt(self.renewed) or self.renewed, | ||||
|         ) | ||||
|  | ||||
|     @staticmethod | ||||
| @@ -171,9 +214,30 @@ class SessionModel(Base): | ||||
|         return SessionModel( | ||||
|             key=session.key, | ||||
|             user_uuid=session.user_uuid.bytes, | ||||
|             credential_uuid=session.credential_uuid and session.credential_uuid.bytes, | ||||
|             expires=session.expires, | ||||
|             info=session.info, | ||||
|             credential_uuid=session.credential_uuid.bytes, | ||||
|             host=session.host, | ||||
|             ip=session.ip, | ||||
|             user_agent=session.user_agent, | ||||
|             renewed=session.renewed, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ResetTokenModel(Base): | ||||
|     __tablename__ = "reset_tokens" | ||||
|  | ||||
|     key: Mapped[bytes] = mapped_column(LargeBinary(16), primary_key=True) | ||||
|     user_uuid: Mapped[bytes] = mapped_column( | ||||
|         LargeBinary(16), ForeignKey("users.uuid", ondelete="CASCADE"), nullable=False | ||||
|     ) | ||||
|     token_type: Mapped[str] = mapped_column(String, nullable=False) | ||||
|     expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) | ||||
|  | ||||
|     def as_dataclass(self) -> ResetToken: | ||||
|         return ResetToken( | ||||
|             key=self.key, | ||||
|             user_uuid=UUID(bytes=self.user_uuid), | ||||
|             token_type=self.token_type, | ||||
|             expiry=_normalize_dt(self.expiry) or self.expiry, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -257,6 +321,58 @@ class DB(DatabaseInterface): | ||||
|         """Initialize database tables.""" | ||||
|         async with self.engine.begin() as conn: | ||||
|             await conn.run_sync(Base.metadata.create_all) | ||||
|             result = await conn.execute(text("PRAGMA table_info('sessions')")) | ||||
|             columns = {row[1] for row in result} | ||||
|             expected = { | ||||
|                 "key", | ||||
|                 "user_uuid", | ||||
|                 "credential_uuid", | ||||
|                 "host", | ||||
|                 "ip", | ||||
|                 "user_agent", | ||||
|                 "renewed", | ||||
|             } | ||||
|             needs_recreate = False | ||||
|             if columns and columns != expected: | ||||
|                 await conn.execute(text("DROP TABLE sessions")) | ||||
|                 needs_recreate = True | ||||
|             result = await conn.execute(text("PRAGMA table_info('reset_tokens')")) | ||||
|             if not list(result): | ||||
|                 needs_recreate = True | ||||
|             if needs_recreate: | ||||
|                 await conn.run_sync(Base.metadata.create_all) | ||||
|         # Run one-time migration to add UTC tzinfo to any naive datetimes | ||||
|         await self._migrate_naive_datetimes() | ||||
|  | ||||
|     async def _migrate_naive_datetimes(self) -> None: | ||||
|         """Attach UTC tzinfo to any legacy naive datetime rows. | ||||
|  | ||||
|         SQLite stores datetimes as text; older rows may have been inserted naive. | ||||
|         We treat naive timestamps as already UTC and rewrite them in ISO8601 with Z. | ||||
|         """ | ||||
|         # Helper SQL fragment for detecting naive (no timezone offset) for ISO strings | ||||
|         # We only update rows whose textual representation lacks a 'Z' or '+' sign. | ||||
|         async with self.session() as session: | ||||
|             # Users | ||||
|             for model, fields in [ | ||||
|                 (UserModel, ["created_at", "last_seen"]), | ||||
|                 (CredentialModel, ["created_at", "last_used", "last_verified"]), | ||||
|                 (SessionModel, ["renewed"]), | ||||
|                 (ResetTokenModel, ["expiry"]), | ||||
|             ]: | ||||
|                 stmt = select(model) | ||||
|                 result = await session.execute(stmt) | ||||
|                 rows = result.scalars().all() | ||||
|                 dirty = False | ||||
|                 for row in rows: | ||||
|                     for fname in fields: | ||||
|                         value = getattr(row, fname, None) | ||||
|                         if isinstance(value, datetime) and value.tzinfo is None: | ||||
|                             setattr(row, fname, value.replace(tzinfo=timezone.utc)) | ||||
|                             dirty = True | ||||
|                 if dirty: | ||||
|                     # SQLAlchemy autoflush/commit in context manager will persist | ||||
|                     pass | ||||
|  | ||||
|     async def get_user_by_uuid(self, user_uuid: UUID) -> User: | ||||
|         async with self.session() as session: | ||||
| @@ -409,9 +525,11 @@ class DB(DatabaseInterface): | ||||
|         credential: Credential, | ||||
|         reset_key: bytes | None, | ||||
|         session_key: bytes, | ||||
|         session_expires: datetime, | ||||
|         session_info: dict, | ||||
|         *, | ||||
|         display_name: str | None = None, | ||||
|         host: str | None = None, | ||||
|         ip: str | None = None, | ||||
|         user_agent: str | None = None, | ||||
|     ) -> None: | ||||
|         """Atomic credential + (optional old session delete) + (optional rename) + new session.""" | ||||
|         async with self.session() as session: | ||||
| @@ -434,10 +552,10 @@ class DB(DatabaseInterface): | ||||
|                     last_verified=credential.last_verified, | ||||
|                 ) | ||||
|             ) | ||||
|             # Delete old session if provided | ||||
|             # Delete old reset token if provided | ||||
|             if reset_key: | ||||
|                 await session.execute( | ||||
|                     delete(SessionModel).where(SessionModel.key == reset_key) | ||||
|                     delete(ResetTokenModel).where(ResetTokenModel.key == reset_key) | ||||
|                 ) | ||||
|             # Optional rename | ||||
|             if display_name: | ||||
| @@ -452,8 +570,9 @@ class DB(DatabaseInterface): | ||||
|                     key=session_key, | ||||
|                     user_uuid=user_uuid.bytes, | ||||
|                     credential_uuid=credential.uuid.bytes, | ||||
|                     expires=session_expires, | ||||
|                     info=session_info, | ||||
|                     host=host, | ||||
|                     ip=ip, | ||||
|                     user_agent=user_agent, | ||||
|                 ) | ||||
|             ) | ||||
|             # Login side-effects: update user analytics (last_seen + visits increment) | ||||
| @@ -476,17 +595,21 @@ class DB(DatabaseInterface): | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         key: bytes, | ||||
|         expires: datetime, | ||||
|         info: dict, | ||||
|         credential_uuid: UUID | None = None, | ||||
|         credential_uuid: UUID, | ||||
|         host: str, | ||||
|         ip: str, | ||||
|         user_agent: str, | ||||
|         renewed: datetime, | ||||
|     ) -> None: | ||||
|         async with self.session() as session: | ||||
|             session_model = SessionModel( | ||||
|                 key=key, | ||||
|                 user_uuid=user_uuid.bytes, | ||||
|                 credential_uuid=credential_uuid.bytes if credential_uuid else None, | ||||
|                 expires=expires, | ||||
|                 info=info, | ||||
|                 credential_uuid=credential_uuid.bytes, | ||||
|                 host=host, | ||||
|                 ip=ip, | ||||
|                 user_agent=user_agent, | ||||
|                 renewed=renewed, | ||||
|             ) | ||||
|             session.add(session_model) | ||||
|  | ||||
| @@ -497,29 +620,88 @@ class DB(DatabaseInterface): | ||||
|             session_model = result.scalar_one_or_none() | ||||
|  | ||||
|             if session_model: | ||||
|                 return Session( | ||||
|                     key=session_model.key, | ||||
|                     user_uuid=UUID(bytes=session_model.user_uuid), | ||||
|                     credential_uuid=UUID(bytes=session_model.credential_uuid) | ||||
|                     if session_model.credential_uuid | ||||
|                     else None, | ||||
|                     expires=session_model.expires, | ||||
|                     info=session_model.info or {}, | ||||
|                 ) | ||||
|                 return session_model.as_dataclass() | ||||
|             return None | ||||
|  | ||||
|     async def delete_session(self, key: bytes) -> None: | ||||
|         async with self.session() as session: | ||||
|             await session.execute(delete(SessionModel).where(SessionModel.key == key)) | ||||
|  | ||||
|     async def update_session(self, key: bytes, expires: datetime, info: dict) -> None: | ||||
|     async def delete_sessions_for_user(self, user_uuid: UUID) -> None: | ||||
|         async with self.session() as session: | ||||
|             await session.execute( | ||||
|                 update(SessionModel) | ||||
|                 .where(SessionModel.key == key) | ||||
|                 .values(expires=expires, info=info) | ||||
|                 delete(SessionModel).where(SessionModel.user_uuid == user_uuid.bytes) | ||||
|             ) | ||||
|  | ||||
|     async def create_reset_token( | ||||
|         self, | ||||
|         user_uuid: UUID, | ||||
|         key: bytes, | ||||
|         expiry: datetime, | ||||
|         token_type: str, | ||||
|     ) -> None: | ||||
|         async with self.session() as session: | ||||
|             model = ResetTokenModel( | ||||
|                 key=key, | ||||
|                 user_uuid=user_uuid.bytes, | ||||
|                 token_type=token_type, | ||||
|                 expiry=expiry, | ||||
|             ) | ||||
|             session.add(model) | ||||
|  | ||||
|     async def get_reset_token(self, key: bytes) -> ResetToken | None: | ||||
|         async with self.session() as session: | ||||
|             stmt = select(ResetTokenModel).where(ResetTokenModel.key == key) | ||||
|             result = await session.execute(stmt) | ||||
|             model = result.scalar_one_or_none() | ||||
|             return model.as_dataclass() if model else None | ||||
|  | ||||
|     async def delete_reset_token(self, key: bytes) -> None: | ||||
|         async with self.session() as session: | ||||
|             await session.execute( | ||||
|                 delete(ResetTokenModel).where(ResetTokenModel.key == key) | ||||
|             ) | ||||
|  | ||||
|     async def update_session( | ||||
|         self, | ||||
|         key: bytes, | ||||
|         *, | ||||
|         ip: str, | ||||
|         user_agent: str, | ||||
|         renewed: datetime, | ||||
|     ) -> Session | None: | ||||
|         async with self.session() as session: | ||||
|             model = await session.get(SessionModel, key) | ||||
|             if not model: | ||||
|                 return None | ||||
|             model.ip = ip | ||||
|             model.user_agent = user_agent | ||||
|             model.renewed = renewed | ||||
|             await session.flush() | ||||
|             return model.as_dataclass() | ||||
|  | ||||
|     async def set_session_host(self, key: bytes, host: str) -> None: | ||||
|         async with self.session() as session: | ||||
|             model = await session.get(SessionModel, key) | ||||
|             if model and model.host is None: | ||||
|                 model.host = host | ||||
|                 await session.flush() | ||||
|  | ||||
|     async def list_sessions_for_user(self, user_uuid: UUID) -> list[Session]: | ||||
|         async with self.session() as session: | ||||
|             stmt = ( | ||||
|                 select(SessionModel) | ||||
|                 .where(SessionModel.user_uuid == user_uuid.bytes) | ||||
|                 .order_by(SessionModel.renewed.desc()) | ||||
|             ) | ||||
|             result = await session.execute(stmt) | ||||
|             session_models = [ | ||||
|                 model | ||||
|                 for model in result.scalars().all() | ||||
|                 if model.key.startswith(b"sess") | ||||
|             ] | ||||
|             return [model.as_dataclass() for model in session_models] | ||||
|  | ||||
|     # Organization operations | ||||
|     async def create_organization(self, org: Org) -> None: | ||||
|         async with self.session() as session: | ||||
| @@ -1115,11 +1297,18 @@ class DB(DatabaseInterface): | ||||
|  | ||||
|     async def cleanup(self) -> None: | ||||
|         async with self.session() as session: | ||||
|             current_time = datetime.now() | ||||
|             stmt = delete(SessionModel).where(SessionModel.expires < current_time) | ||||
|             await session.execute(stmt) | ||||
|             current_time = datetime.now(timezone.utc) | ||||
|             session_threshold = current_time - SESSION_LIFETIME | ||||
|             await session.execute( | ||||
|                 delete(SessionModel).where(SessionModel.renewed < session_threshold) | ||||
|             ) | ||||
|             await session.execute( | ||||
|                 delete(ResetTokenModel).where(ResetTokenModel.expiry < current_time) | ||||
|             ) | ||||
|  | ||||
|     async def get_session_context(self, session_key: bytes) -> SessionContext | None: | ||||
|     async def get_session_context( | ||||
|         self, session_key: bytes, host: str | None = None | ||||
|     ) -> SessionContext | None: | ||||
|         """Get complete session context including user, organization, role, and permissions. | ||||
|  | ||||
|         Uses efficient JOINs to retrieve all related data in a single database query. | ||||
| @@ -1156,15 +1345,18 @@ class DB(DatabaseInterface): | ||||
|             session_model, user_model, role_model, org_model, _ = first_row | ||||
|  | ||||
|             # Create the session object | ||||
|             session_obj = Session( | ||||
|                 key=session_model.key, | ||||
|                 user_uuid=UUID(bytes=session_model.user_uuid), | ||||
|                 credential_uuid=UUID(bytes=session_model.credential_uuid) | ||||
|                 if session_model.credential_uuid | ||||
|                 else None, | ||||
|                 expires=session_model.expires, | ||||
|                 info=session_model.info or {}, | ||||
|             ) | ||||
|             if host is not None: | ||||
|                 if session_model.host is None: | ||||
|                     await session.execute( | ||||
|                         update(SessionModel) | ||||
|                         .where(SessionModel.key == session_key) | ||||
|                         .values(host=host) | ||||
|                     ) | ||||
|                     session_model.host = host | ||||
|                 elif session_model.host != host: | ||||
|                     return None | ||||
|  | ||||
|             session_obj = session_model.as_dataclass() | ||||
|  | ||||
|             # Create the user object | ||||
|             user_obj = user_model.as_dataclass() | ||||
|   | ||||
| @@ -14,6 +14,27 @@ DEFAULT_SERVE_PORT = 4401 | ||||
| DEFAULT_DEV_PORT = 4402 | ||||
|  | ||||
|  | ||||
| def is_subdomain(sub: str, domain: str) -> bool: | ||||
|     """Check if sub is a subdomain of domain (or equal).""" | ||||
|     sub_parts = sub.lower().split(".") | ||||
|     domain_parts = domain.lower().split(".") | ||||
|     if len(sub_parts) < len(domain_parts): | ||||
|         return False | ||||
|     return sub_parts[-len(domain_parts) :] == domain_parts | ||||
|  | ||||
|  | ||||
| def validate_auth_host(auth_host: str, rp_id: str) -> None: | ||||
|     """Validate that auth_host is a subdomain of rp_id.""" | ||||
|     parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}") | ||||
|     host = parsed.hostname or parsed.path | ||||
|     if not host: | ||||
|         raise SystemExit(f"Invalid auth-host: '{auth_host}'") | ||||
|     if not is_subdomain(host, rp_id): | ||||
|         raise SystemExit( | ||||
|             f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def parse_endpoint( | ||||
|     value: str | None, default_port: int | ||||
| ) -> tuple[str | None, int | None, str | None, bool]: | ||||
| @@ -181,7 +202,8 @@ def main(): | ||||
|         # Preserve pre-set env variable if CLI option omitted | ||||
|         args.auth_host = os.environ.get("PASSKEY_AUTH_HOST") | ||||
|  | ||||
|     if getattr(args, "auth_host", None): | ||||
|     if args.auth_host: | ||||
|         validate_auth_host(args.auth_host, args.rp_id) | ||||
|         from passkey.util import hostutil as _hostutil  # local import | ||||
|  | ||||
|         _hostutil.reload_config() | ||||
|   | ||||
| @@ -1,13 +1,24 @@ | ||||
| import logging | ||||
| from datetime import timezone | ||||
| from uuid import UUID, uuid4 | ||||
|  | ||||
| from fastapi import Body, Cookie, FastAPI, HTTPException, Request | ||||
| from fastapi import Body, FastAPI, HTTPException, Request | ||||
| from fastapi.responses import FileResponse, JSONResponse | ||||
|  | ||||
| from ..authsession import expires | ||||
| from ..authsession import reset_expires | ||||
| from ..globals import db | ||||
| from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens | ||||
| from ..util import ( | ||||
|     frontend, | ||||
|     hostutil, | ||||
|     passphrase, | ||||
|     permutil, | ||||
|     querysafe, | ||||
|     tokens, | ||||
|     useragent, | ||||
| ) | ||||
| from ..util.tokens import encode_session_key, session_key | ||||
| from . import authz | ||||
| from .session import AUTH_COOKIE | ||||
|  | ||||
| app = FastAPI() | ||||
|  | ||||
| @@ -24,20 +35,36 @@ async def general_exception_handler(_request, exc: Exception): | ||||
|  | ||||
|  | ||||
| @app.get("/") | ||||
| async def adminapp(auth=Cookie(None)): | ||||
| async def adminapp(request: Request, auth=AUTH_COOKIE): | ||||
|     """Serve admin SPA only for authenticated users with admin/org permissions. | ||||
|  | ||||
|     On missing/invalid session or insufficient permissions, serve restricted SPA. | ||||
|     """ | ||||
|     try: | ||||
|         await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) | ||||
|         await authz.verify( | ||||
|             auth, | ||||
|             ["auth:admin", "auth:org:*"], | ||||
|             match=permutil.has_any, | ||||
|             host=request.headers.get("host"), | ||||
|         ) | ||||
|         return FileResponse(frontend.file("admin/index.html")) | ||||
|     except HTTPException as e: | ||||
|         return FileResponse(frontend.file("index.html"), status_code=e.status_code) | ||||
|         return FileResponse( | ||||
|             frontend.file("restricted", "index.html"), status_code=e.status_code | ||||
|         ) | ||||
|  | ||||
|  | ||||
| # -------------------- Organizations -------------------- | ||||
|  | ||||
|  | ||||
| @app.get("/orgs") | ||||
| async def admin_list_orgs(auth=Cookie(None)): | ||||
|     ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) | ||||
| async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): | ||||
|     ctx = await authz.verify( | ||||
|         auth, | ||||
|         ["auth:admin", "auth:org:*"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     orgs = await db.instance.list_organizations() | ||||
|     if "auth:admin" not in ctx.role.permissions: | ||||
|         orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions] | ||||
| @@ -73,8 +100,12 @@ async def admin_list_orgs(auth=Cookie(None)): | ||||
|  | ||||
|  | ||||
| @app.post("/orgs") | ||||
| async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
| async def admin_create_org( | ||||
|     request: Request, payload: dict = Body(...), auth=AUTH_COOKIE | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     from ..db import Org as OrgDC  # local import to avoid cycles | ||||
|     from ..db import Role as RoleDC  # local import to avoid cycles | ||||
|  | ||||
| @@ -99,10 +130,16 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): | ||||
|  | ||||
| @app.put("/orgs/{org_uuid}") | ||||
| async def admin_update_org( | ||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     from ..db import Org as OrgDC  # local import to avoid cycles | ||||
|  | ||||
| @@ -129,9 +166,12 @@ async def admin_update_org( | ||||
|  | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}") | ||||
| async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): | ||||
| async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     if ctx.org.uuid == org_uuid: | ||||
|         raise ValueError("Cannot delete the organization you belong to") | ||||
| @@ -156,18 +196,28 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): | ||||
|  | ||||
| @app.post("/orgs/{org_uuid}/permission") | ||||
| async def admin_add_org_permission( | ||||
|     org_uuid: UUID, permission_id: str, auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     permission_id: str, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     await db.instance.add_permission_to_organization(str(org_uuid), permission_id) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}/permission") | ||||
| async def admin_remove_org_permission( | ||||
|     org_uuid: UUID, permission_id: str, auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     permission_id: str, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     await db.instance.remove_permission_from_organization(str(org_uuid), permission_id) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
| @@ -177,10 +227,16 @@ async def admin_remove_org_permission( | ||||
|  | ||||
| @app.post("/orgs/{org_uuid}/roles") | ||||
| async def admin_create_role( | ||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     from ..db import Role as RoleDC | ||||
|  | ||||
| @@ -205,11 +261,18 @@ async def admin_create_role( | ||||
|  | ||||
| @app.put("/orgs/{org_uuid}/roles/{role_uuid}") | ||||
| async def admin_update_role( | ||||
|     org_uuid: UUID, role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     role_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     # Verify caller is global admin or admin of provided org | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     role = await db.instance.get_role(role_uuid) | ||||
|     if role.org_uuid != org_uuid: | ||||
| @@ -247,9 +310,17 @@ async def admin_update_role( | ||||
|  | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}/roles/{role_uuid}") | ||||
| async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): | ||||
| async def admin_delete_role( | ||||
|     org_uuid: UUID, | ||||
|     role_uuid: UUID, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     role = await db.instance.get_role(role_uuid) | ||||
|     if role.org_uuid != org_uuid: | ||||
| @@ -268,10 +339,16 @@ async def admin_delete_role(org_uuid: UUID, role_uuid: UUID, auth=Cookie(None)): | ||||
|  | ||||
| @app.post("/orgs/{org_uuid}/users") | ||||
| async def admin_create_user( | ||||
|     org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     display_name = payload.get("display_name") | ||||
|     role_name = payload.get("role") | ||||
| @@ -297,10 +374,17 @@ async def admin_create_user( | ||||
|  | ||||
| @app.put("/orgs/{org_uuid}/users/{user_uuid}/role") | ||||
| async def admin_update_user_role( | ||||
|     org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     user_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     new_role = payload.get("role") | ||||
|     if not new_role: | ||||
| @@ -334,7 +418,10 @@ 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, request: Request, auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     user_uuid: UUID, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) | ||||
| @@ -343,28 +430,49 @@ async def admin_create_user_registration_link( | ||||
|     if user_org.uuid != org_uuid: | ||||
|         raise HTTPException(status_code=404, detail="User not found in organization") | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
|         and f"auth:org:{org_uuid}" not in ctx.role.permissions | ||||
|     ): | ||||
|         raise HTTPException(status_code=403, detail="Insufficient permissions") | ||||
|  | ||||
|     # Check if user has existing credentials | ||||
|     credentials = await db.instance.get_credentials_by_user_uuid(user_uuid) | ||||
|     token_type = "user registration" if not credentials else "account recovery" | ||||
|  | ||||
|     token = passphrase.generate() | ||||
|     await db.instance.create_session( | ||||
|     expiry = reset_expires() | ||||
|     await db.instance.create_reset_token( | ||||
|         user_uuid=user_uuid, | ||||
|         key=tokens.reset_key(token), | ||||
|         expires=expires(), | ||||
|         info={"type": "device addition", "created_by_admin": True}, | ||||
|         expiry=expiry, | ||||
|         token_type=token_type, | ||||
|     ) | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     return {"url": url, "expires": expires().isoformat()} | ||||
|     return { | ||||
|         "url": url, | ||||
|         "expires": ( | ||||
|             expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|             if expiry.tzinfo | ||||
|             else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|         ), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.get("/orgs/{org_uuid}/users/{user_uuid}") | ||||
| async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(None)): | ||||
| async def admin_get_user_detail( | ||||
|     org_uuid: UUID, | ||||
|     user_uuid: UUID, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         user_org, role_name = await db.instance.get_user_organization(user_uuid) | ||||
|     except ValueError: | ||||
| @@ -372,7 +480,10 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non | ||||
|     if user_org.uuid != org_uuid: | ||||
|         raise HTTPException(status_code=404, detail="User not found in organization") | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
| @@ -394,9 +505,41 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non | ||||
|             { | ||||
|                 "credential_uuid": str(c.uuid), | ||||
|                 "aaguid": aaguid_str, | ||||
|                 "created_at": c.created_at.isoformat(), | ||||
|                 "last_used": c.last_used.isoformat() if c.last_used else None, | ||||
|                 "last_verified": c.last_verified.isoformat() | ||||
|                 "created_at": ( | ||||
|                     c.created_at.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.created_at.tzinfo | ||||
|                     else c.created_at.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                 ), | ||||
|                 "last_used": ( | ||||
|                     c.last_used.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.last_used and c.last_used.tzinfo | ||||
|                     else ( | ||||
|                         c.last_used.replace(tzinfo=timezone.utc) | ||||
|                         .isoformat() | ||||
|                         .replace("+00:00", "Z") | ||||
|                         if c.last_used | ||||
|                         else None | ||||
|                     ) | ||||
|                 ), | ||||
|                 "last_verified": ( | ||||
|                     c.last_verified.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.last_verified and c.last_verified.tzinfo | ||||
|                     else ( | ||||
|                         c.last_verified.replace(tzinfo=timezone.utc) | ||||
|                         .isoformat() | ||||
|                         .replace("+00:00", "Z") | ||||
|                         if c.last_verified | ||||
|                         else None | ||||
|                     ) | ||||
|                 ) | ||||
|                 if c.last_verified | ||||
|                 else None, | ||||
|                 "sign_count": c.sign_count, | ||||
| @@ -405,21 +548,77 @@ async def admin_get_user_detail(org_uuid: UUID, user_uuid: UUID, auth=Cookie(Non | ||||
|     from .. import aaguid as aaguid_mod | ||||
|  | ||||
|     aaguid_info = aaguid_mod.filter(aaguids) | ||||
|  | ||||
|     # Get sessions for the user | ||||
|     normalized_request_host = hostutil.normalize_host(request.headers.get("host")) | ||||
|     session_records = await db.instance.list_sessions_for_user(user_uuid) | ||||
|     current_session_key = session_key(auth) | ||||
|     sessions_payload: list[dict] = [] | ||||
|     for entry in session_records: | ||||
|         sessions_payload.append( | ||||
|             { | ||||
|                 "id": encode_session_key(entry.key), | ||||
|                 "host": entry.host, | ||||
|                 "ip": entry.ip, | ||||
|                 "user_agent": useragent.compact_user_agent(entry.user_agent), | ||||
|                 "last_renewed": ( | ||||
|                     entry.renewed.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if entry.renewed.tzinfo | ||||
|                     else entry.renewed.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                 ), | ||||
|                 "is_current": entry.key == current_session_key, | ||||
|                 "is_current_host": bool( | ||||
|                     normalized_request_host | ||||
|                     and entry.host | ||||
|                     and entry.host == normalized_request_host | ||||
|                 ), | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     return { | ||||
|         "display_name": user.display_name, | ||||
|         "org": {"display_name": user_org.display_name}, | ||||
|         "role": role_name, | ||||
|         "visits": user.visits, | ||||
|         "created_at": user.created_at.isoformat() if user.created_at else None, | ||||
|         "last_seen": user.last_seen.isoformat() if user.last_seen else None, | ||||
|         "created_at": ( | ||||
|             user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|             if user.created_at and user.created_at.tzinfo | ||||
|             else ( | ||||
|                 user.created_at.replace(tzinfo=timezone.utc) | ||||
|                 .isoformat() | ||||
|                 .replace("+00:00", "Z") | ||||
|                 if user.created_at | ||||
|                 else None | ||||
|             ) | ||||
|         ), | ||||
|         "last_seen": ( | ||||
|             user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|             if user.last_seen and user.last_seen.tzinfo | ||||
|             else ( | ||||
|                 user.last_seen.replace(tzinfo=timezone.utc) | ||||
|                 .isoformat() | ||||
|                 .replace("+00:00", "Z") | ||||
|                 if user.last_seen | ||||
|                 else None | ||||
|             ) | ||||
|         ), | ||||
|         "credentials": creds, | ||||
|         "aaguid_info": aaguid_info, | ||||
|         "sessions": sessions_payload, | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") | ||||
| async def admin_update_user_display_name( | ||||
|     org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     user_uuid: UUID, | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) | ||||
| @@ -428,7 +627,10 @@ async def admin_update_user_display_name( | ||||
|     if user_org.uuid != org_uuid: | ||||
|         raise HTTPException(status_code=404, detail="User not found in organization") | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
| @@ -446,7 +648,11 @@ async def admin_update_user_display_name( | ||||
|  | ||||
| @app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}") | ||||
| async def admin_delete_user_credential( | ||||
|     org_uuid: UUID, user_uuid: UUID, credential_uuid: UUID, auth=Cookie(None) | ||||
|     org_uuid: UUID, | ||||
|     user_uuid: UUID, | ||||
|     credential_uuid: UUID, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         user_org, _role_name = await db.instance.get_user_organization(user_uuid) | ||||
| @@ -455,7 +661,10 @@ async def admin_delete_user_credential( | ||||
|     if user_org.uuid != org_uuid: | ||||
|         raise HTTPException(status_code=404, detail="User not found in organization") | ||||
|     ctx = await authz.verify( | ||||
|         auth, ["auth:admin", f"auth:org:{org_uuid}"], match=permutil.has_any | ||||
|         auth, | ||||
|         ["auth:admin", f"auth:org:{org_uuid}"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     if ( | ||||
|         "auth:admin" not in ctx.role.permissions | ||||
| @@ -470,8 +679,13 @@ async def admin_delete_user_credential( | ||||
|  | ||||
|  | ||||
| @app.get("/permissions") | ||||
| async def admin_list_permissions(auth=Cookie(None)): | ||||
|     ctx = await authz.verify(auth, ["auth:admin", "auth:org:*"], match=permutil.has_any) | ||||
| async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): | ||||
|     ctx = await authz.verify( | ||||
|         auth, | ||||
|         ["auth:admin", "auth:org:*"], | ||||
|         match=permutil.has_any, | ||||
|         host=request.headers.get("host"), | ||||
|     ) | ||||
|     perms = await db.instance.list_permissions() | ||||
|  | ||||
|     # Global admins see all permissions | ||||
| @@ -485,8 +699,14 @@ async def admin_list_permissions(auth=Cookie(None)): | ||||
|  | ||||
|  | ||||
| @app.post("/permissions") | ||||
| async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
| async def admin_create_permission( | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     from ..db import Permission as PermDC | ||||
|  | ||||
|     perm_id = payload.get("id") | ||||
| @@ -500,9 +720,14 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
|  | ||||
| @app.put("/permission") | ||||
| async def admin_update_permission( | ||||
|     permission_id: str, display_name: str, auth=Cookie(None) | ||||
|     permission_id: str, | ||||
|     display_name: str, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     from ..db import Permission as PermDC | ||||
|  | ||||
|     if not display_name: | ||||
| @@ -515,8 +740,14 @@ async def admin_update_permission( | ||||
|  | ||||
|  | ||||
| @app.post("/permission/rename") | ||||
| async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
| async def admin_rename_permission( | ||||
|     request: Request, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     old_id = payload.get("old_id") | ||||
|     new_id = payload.get("new_id") | ||||
|     display_name = payload.get("display_name") | ||||
| @@ -540,8 +771,14 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)): | ||||
|  | ||||
|  | ||||
| @app.delete("/permission") | ||||
| async def admin_delete_permission(permission_id: str, auth=Cookie(None)): | ||||
|     await authz.verify(auth, ["auth:admin"]) | ||||
| async def admin_delete_permission( | ||||
|     permission_id: str, | ||||
|     request: Request, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     await authz.verify( | ||||
|         auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all | ||||
|     ) | ||||
|     querysafe.assert_safe(permission_id, field="permission_id") | ||||
|  | ||||
|     # Sanity check: prevent deleting critical permissions | ||||
|   | ||||
| @@ -1,11 +1,8 @@ | ||||
| import logging | ||||
| from contextlib import suppress | ||||
| from datetime import datetime, timedelta | ||||
| from uuid import UUID | ||||
| from datetime import datetime, timedelta, timezone | ||||
|  | ||||
| from fastapi import ( | ||||
|     Body, | ||||
|     Cookie, | ||||
|     Depends, | ||||
|     FastAPI, | ||||
|     HTTPException, | ||||
| @@ -16,27 +13,40 @@ from fastapi import ( | ||||
| from fastapi.responses import JSONResponse | ||||
| from fastapi.security import HTTPBearer | ||||
|  | ||||
| from passkey.util import frontend | ||||
| from passkey.util import frontend, useragent | ||||
|  | ||||
| from .. import aaguid | ||||
| from ..authsession import ( | ||||
|     EXPIRES, | ||||
|     delete_credential, | ||||
|     expires, | ||||
|     get_reset, | ||||
|     get_session, | ||||
|     refresh_session_token, | ||||
|     session_expiry, | ||||
| ) | ||||
| from ..globals import db | ||||
| from ..globals import passkey as global_passkey | ||||
| from ..util import hostutil, passphrase, permutil, tokens | ||||
| from ..util.tokens import session_key | ||||
| from . import authz, session | ||||
| from ..util import hostutil, passphrase, permutil | ||||
| from ..util.tokens import encode_session_key, session_key | ||||
| from . import authz, session, user | ||||
| from .session import AUTH_COOKIE | ||||
|  | ||||
| bearer_auth = HTTPBearer(auto_error=True) | ||||
|  | ||||
| app = FastAPI() | ||||
|  | ||||
| app.mount("/user", user.app) | ||||
|  | ||||
|  | ||||
| @app.exception_handler(HTTPException) | ||||
| async def http_exception_handler(_request: Request, exc: HTTPException): | ||||
|     """Ensure auth cookie is cleared on 401 responses (JSON responses only).""" | ||||
|     if exc.status_code == 401: | ||||
|         resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) | ||||
|         session.clear_session_cookie(resp) | ||||
|         return resp | ||||
|     return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) | ||||
|  | ||||
|  | ||||
| # Refresh only if at least this much of the session lifetime has been *consumed*. | ||||
| # Consumption is derived from (now + EXPIRES) - current_expires. | ||||
| # This guarantees a minimum spacing between DB writes even with frequent /validate calls. | ||||
| @@ -56,7 +66,10 @@ async def general_exception_handler(_request: Request, exc: Exception): | ||||
|  | ||||
| @app.post("/validate") | ||||
| async def validate_token( | ||||
|     response: Response, perm: list[str] = Query([]), auth=Cookie(None) | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     perm: list[str] = Query([]), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     """Validate the current session and extend its expiry. | ||||
|  | ||||
| @@ -64,17 +77,26 @@ async def validate_token( | ||||
|     renewed max-age. This keeps active users logged in without needing a separate | ||||
|     refresh endpoint. | ||||
|     """ | ||||
|     ctx = await authz.verify(auth, perm) | ||||
|     try: | ||||
|         ctx = await authz.verify(auth, perm, host=request.headers.get("host")) | ||||
|     except HTTPException: | ||||
|         # Global handler will clear cookie if 401 | ||||
|         raise | ||||
|     renewed = False | ||||
|     if auth: | ||||
|         consumed = EXPIRES - (ctx.session.expires - datetime.now()) | ||||
|         current_expiry = session_expiry(ctx.session) | ||||
|         consumed = EXPIRES - (current_expiry - datetime.now()) | ||||
|         if not timedelta(0) < consumed < _REFRESH_INTERVAL: | ||||
|             try: | ||||
|                 await refresh_session_token(auth) | ||||
|                 await refresh_session_token( | ||||
|                     auth, | ||||
|                     ip=request.client.host if request.client else "", | ||||
|                     user_agent=request.headers.get("user-agent") or "", | ||||
|                 ) | ||||
|                 session.set_session_cookie(response, auth) | ||||
|                 renewed = True | ||||
|             except ValueError: | ||||
|                 # Session disappeared, e.g. due to concurrent logout | ||||
|                 # Session disappeared, e.g. due to concurrent logout; global handler will clear | ||||
|                 raise HTTPException(status_code=401, detail="Session expired") | ||||
|     return { | ||||
|         "valid": True, | ||||
| @@ -84,7 +106,12 @@ async def validate_token( | ||||
|  | ||||
|  | ||||
| @app.get("/forward") | ||||
| async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)): | ||||
| async def forward_authentication( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     perm: list[str] = Query([]), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     """Forward auth validation for Caddy/Nginx. | ||||
|  | ||||
|     Query Params: | ||||
| @@ -94,7 +121,7 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) | ||||
|     Failure (unauthenticated / unauthorized): 4xx JSON body with detail. | ||||
|     """ | ||||
|     try: | ||||
|         ctx = await authz.verify(auth, perm) | ||||
|         ctx = await authz.verify(auth, perm, host=request.headers.get("host")) | ||||
|         role_permissions = set(ctx.role.permissions or []) | ||||
|         if ctx.permissions: | ||||
|             role_permissions.update(permission.id for permission in ctx.permissions) | ||||
| @@ -107,13 +134,28 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) | ||||
|             "Remote-Org-Name": ctx.org.display_name, | ||||
|             "Remote-Role": str(ctx.role.uuid), | ||||
|             "Remote-Role-Name": ctx.role.display_name, | ||||
|             "Remote-Session-Expires": ctx.session.expires.isoformat(), | ||||
|             "Remote-Session-Expires": ( | ||||
|                 session_expiry(ctx.session) | ||||
|                 .astimezone(timezone.utc) | ||||
|                 .isoformat() | ||||
|                 .replace("+00:00", "Z") | ||||
|                 if session_expiry(ctx.session).tzinfo | ||||
|                 else session_expiry(ctx.session) | ||||
|                 .replace(tzinfo=timezone.utc) | ||||
|                 .isoformat() | ||||
|                 .replace("+00:00", "Z") | ||||
|             ), | ||||
|             "Remote-Credential": str(ctx.session.credential_uuid), | ||||
|         } | ||||
|         return Response(status_code=204, headers=remote_headers) | ||||
|     except HTTPException as e: | ||||
|         # Let global handler clear cookie; still return HTML surface instead of JSON | ||||
|         html = frontend.file("restricted", "index.html").read_bytes() | ||||
|         return Response(html, status_code=e.status_code, media_type="text/html") | ||||
|         status = e.status_code | ||||
|         # If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML) | ||||
|         if status == 401: | ||||
|             session.clear_session_cookie(response) | ||||
|         return Response(html, status_code=status, media_type="text/html") | ||||
|  | ||||
|  | ||||
| @app.get("/settings") | ||||
| @@ -129,34 +171,46 @@ async def get_settings(): | ||||
|  | ||||
|  | ||||
| @app.post("/user-info") | ||||
| async def api_user_info(reset: str | None = None, auth=Cookie(None)): | ||||
| async def api_user_info( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     reset: str | None = None, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     authenticated = False | ||||
|     session_record = None | ||||
|     reset_token = None | ||||
|     try: | ||||
|         if reset: | ||||
|             if not passphrase.is_well_formed(reset): | ||||
|                 raise ValueError("Invalid reset token") | ||||
|             s = await get_reset(reset) | ||||
|             reset_token = await get_reset(reset) | ||||
|             target_user_uuid = reset_token.user_uuid | ||||
|         else: | ||||
|             if auth is None: | ||||
|                 raise ValueError("Authentication Required") | ||||
|             s = await get_session(auth) | ||||
|             session_record = await get_session(auth, host=request.headers.get("host")) | ||||
|             authenticated = True | ||||
|             target_user_uuid = session_record.user_uuid | ||||
|     except ValueError as e: | ||||
|         raise HTTPException(401, str(e)) | ||||
|  | ||||
|     u = await db.instance.get_user_by_uuid(s.user_uuid) | ||||
|     u = await db.instance.get_user_by_uuid(target_user_uuid) | ||||
|  | ||||
|     if not authenticated:  # minimal response for reset tokens | ||||
|     if not authenticated and reset_token:  # minimal response for reset tokens | ||||
|         return { | ||||
|             "authenticated": False, | ||||
|             "session_type": s.info.get("type"), | ||||
|             "session_type": reset_token.token_type, | ||||
|             "user": {"user_uuid": str(u.uuid), "user_name": u.display_name}, | ||||
|         } | ||||
|  | ||||
|     assert authenticated and auth is not None | ||||
|     assert auth is not None | ||||
|     assert session_record is not None | ||||
|  | ||||
|     ctx = await permutil.session_context(auth) | ||||
|     credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) | ||||
|     ctx = await permutil.session_context(auth, request.headers.get("host")) | ||||
|     credential_ids = await db.instance.get_credentials_by_user_uuid( | ||||
|         session_record.user_uuid | ||||
|     ) | ||||
|     credentials: list[dict] = [] | ||||
|     user_aaguids: set[str] = set() | ||||
|     for cred_id in credential_ids: | ||||
| @@ -170,13 +224,45 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): | ||||
|             { | ||||
|                 "credential_uuid": str(c.uuid), | ||||
|                 "aaguid": aaguid_str, | ||||
|                 "created_at": c.created_at.isoformat(), | ||||
|                 "last_used": c.last_used.isoformat() if c.last_used else None, | ||||
|                 "last_verified": c.last_verified.isoformat() | ||||
|                 "created_at": ( | ||||
|                     c.created_at.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.created_at.tzinfo | ||||
|                     else c.created_at.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                 ), | ||||
|                 "last_used": ( | ||||
|                     c.last_used.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.last_used and c.last_used.tzinfo | ||||
|                     else ( | ||||
|                         c.last_used.replace(tzinfo=timezone.utc) | ||||
|                         .isoformat() | ||||
|                         .replace("+00:00", "Z") | ||||
|                         if c.last_used | ||||
|                         else None | ||||
|                     ) | ||||
|                 ), | ||||
|                 "last_verified": ( | ||||
|                     c.last_verified.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if c.last_verified and c.last_verified.tzinfo | ||||
|                     else ( | ||||
|                         c.last_verified.replace(tzinfo=timezone.utc) | ||||
|                         .isoformat() | ||||
|                         .replace("+00:00", "Z") | ||||
|                         if c.last_verified | ||||
|                         else None | ||||
|                     ) | ||||
|                 ) | ||||
|                 if c.last_verified | ||||
|                 else None, | ||||
|                 "sign_count": c.sign_count, | ||||
|                 "is_current_session": s.credential_uuid == c.uuid, | ||||
|                 "is_current_session": session_record.credential_uuid == c.uuid, | ||||
|             } | ||||
|         ) | ||||
|     credentials.sort(key=lambda cred: cred["created_at"]) | ||||
| @@ -204,14 +290,62 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): | ||||
|             p.startswith("auth:org:") for p in (role_info["permissions"] or []) | ||||
|         ) | ||||
|  | ||||
|     normalized_request_host = hostutil.normalize_host(request.headers.get("host")) | ||||
|     session_records = await db.instance.list_sessions_for_user(session_record.user_uuid) | ||||
|     current_session_key = session_key(auth) | ||||
|     sessions_payload: list[dict] = [] | ||||
|     for entry in session_records: | ||||
|         sessions_payload.append( | ||||
|             { | ||||
|                 "id": encode_session_key(entry.key), | ||||
|                 "host": entry.host, | ||||
|                 "ip": entry.ip, | ||||
|                 "user_agent": useragent.compact_user_agent(entry.user_agent), | ||||
|                 "last_renewed": ( | ||||
|                     entry.renewed.astimezone(timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if entry.renewed.tzinfo | ||||
|                     else entry.renewed.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                 ), | ||||
|                 "is_current": entry.key == current_session_key, | ||||
|                 "is_current_host": bool( | ||||
|                     normalized_request_host | ||||
|                     and entry.host | ||||
|                     and entry.host == normalized_request_host | ||||
|                 ), | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     return { | ||||
|         "authenticated": True, | ||||
|         "session_type": s.info.get("type"), | ||||
|         "user": { | ||||
|             "user_uuid": str(u.uuid), | ||||
|             "user_name": u.display_name, | ||||
|             "created_at": u.created_at.isoformat() if u.created_at else None, | ||||
|             "last_seen": u.last_seen.isoformat() if u.last_seen else None, | ||||
|             "created_at": ( | ||||
|                 u.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|                 if u.created_at and u.created_at.tzinfo | ||||
|                 else ( | ||||
|                     u.created_at.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if u.created_at | ||||
|                     else None | ||||
|                 ) | ||||
|             ), | ||||
|             "last_seen": ( | ||||
|                 u.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|                 if u.last_seen and u.last_seen.tzinfo | ||||
|                 else ( | ||||
|                     u.last_seen.replace(tzinfo=timezone.utc) | ||||
|                     .isoformat() | ||||
|                     .replace("+00:00", "Z") | ||||
|                     if u.last_seen | ||||
|                     else None | ||||
|                 ) | ||||
|             ), | ||||
|             "visits": u.visits, | ||||
|         }, | ||||
|         "org": org_info, | ||||
| @@ -221,64 +355,31 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)): | ||||
|         "is_org_admin": is_org_admin, | ||||
|         "credentials": credentials, | ||||
|         "aaguid_info": aaguid_info, | ||||
|         "sessions": sessions_payload, | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.put("/user/display-name") | ||||
| async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)): | ||||
|     if not auth: | ||||
|         raise HTTPException(status_code=401, detail="Authentication Required") | ||||
|     s = await get_session(auth) | ||||
|     new_name = (payload.get("display_name") or "").strip() | ||||
|     if not new_name: | ||||
|         raise HTTPException(status_code=400, detail="display_name required") | ||||
|     if len(new_name) > 64: | ||||
|         raise HTTPException(status_code=400, detail="display_name too long") | ||||
|     await db.instance.update_user_display_name(s.user_uuid, new_name) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| @app.post("/logout") | ||||
| async def api_logout(response: Response, auth=Cookie(None)): | ||||
| async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): | ||||
|     if not auth: | ||||
|         return {"message": "Already logged out"} | ||||
|     try: | ||||
|         await get_session(auth, host=request.headers.get("host")) | ||||
|     except ValueError: | ||||
|         return {"message": "Already logged out"} | ||||
|     with suppress(Exception): | ||||
|         await db.instance.delete_session(session_key(auth)) | ||||
|     response.delete_cookie("auth") | ||||
|     session.clear_session_cookie(response) | ||||
|     return {"message": "Logged out successfully"} | ||||
|  | ||||
|  | ||||
| @app.post("/set-session") | ||||
| async def api_set_session(response: Response, auth=Depends(bearer_auth)): | ||||
|     user = await get_session(auth.credentials) | ||||
| async def api_set_session( | ||||
|     request: Request, response: Response, auth=Depends(bearer_auth) | ||||
| ): | ||||
|     user = await get_session(auth.credentials, host=request.headers.get("host")) | ||||
|     session.set_session_cookie(response, auth.credentials) | ||||
|     return { | ||||
|         "message": "Session cookie set successfully", | ||||
|         "user_uuid": str(user.user_uuid), | ||||
|     } | ||||
|  | ||||
|  | ||||
| @app.delete("/credential/{uuid}") | ||||
| async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)): | ||||
|     await delete_credential(uuid, auth) | ||||
|     return {"message": "Credential deleted successfully"} | ||||
|  | ||||
|  | ||||
| @app.post("/create-link") | ||||
| async def api_create_link(request: Request, auth=Cookie(None)): | ||||
|     s = await get_session(auth) | ||||
|     token = passphrase.generate() | ||||
|     await db.instance.create_session( | ||||
|         user_uuid=s.user_uuid, | ||||
|         key=tokens.reset_key(token), | ||||
|         expires=expires(), | ||||
|         info=session.infodict(request, "device addition"), | ||||
|     ) | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     return { | ||||
|         "message": "Registration link generated successfully", | ||||
|         "url": url, | ||||
|         "expires": expires().isoformat(), | ||||
|     } | ||||
|   | ||||
							
								
								
									
										97
									
								
								passkey/fastapi/auth_host.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								passkey/fastapi/auth_host.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| """Middleware for handling auth host redirects.""" | ||||
|  | ||||
| from fastapi import Request, Response | ||||
| from fastapi.responses import RedirectResponse | ||||
|  | ||||
| from passkey.util import hostutil, passphrase | ||||
|  | ||||
|  | ||||
| def is_ui_path(path: str) -> bool: | ||||
|     """Check if the path is a UI endpoint.""" | ||||
|     ui_paths = { | ||||
|         "/", | ||||
|         "/admin", | ||||
|         "/admin/", | ||||
|         "/auth", | ||||
|         "/auth/", | ||||
|         "/auth/admin", | ||||
|         "/auth/admin/", | ||||
|     } | ||||
|     if path in ui_paths: | ||||
|         return True | ||||
|     # Treat reset token pages as UI (dynamic). Accept single-segment tokens. | ||||
|     if path.startswith("/auth/"): | ||||
|         token = path[6:] | ||||
|         if token and "/" not in token and passphrase.is_well_formed(token): | ||||
|             return True | ||||
|     else: | ||||
|         token = path[1:] | ||||
|         if token and "/" not in token and passphrase.is_well_formed(token): | ||||
|             return True | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def is_restricted_path(path: str) -> bool: | ||||
|     """Check if the path is restricted (API/admin endpoints).""" | ||||
|     return path.startswith(("/auth/api/admin/", "/auth/api/user/", "/auth/ws/")) | ||||
|  | ||||
|  | ||||
| def should_redirect_to_auth_host(path: str) -> bool: | ||||
|     """Determine if the request should be redirected to the auth host.""" | ||||
|     if path in {"/", "/auth", "/auth/"}: | ||||
|         return False | ||||
|     return is_ui_path(path) or is_restricted_path(path) | ||||
|  | ||||
|  | ||||
| def redirect_to_auth_host(request: Request, cfg: str, path: str) -> Response: | ||||
|     """Create a redirect response to the auth host.""" | ||||
|     if is_restricted_path(path): | ||||
|         return Response(status_code=404) | ||||
|     new_path = ( | ||||
|         path[5:] or "/" if is_ui_path(path) and path.startswith("/auth") else path | ||||
|     ) | ||||
|     return RedirectResponse(f"{request.url.scheme}://{cfg}{new_path}", 307) | ||||
|  | ||||
|  | ||||
| def should_redirect_auth_path_to_root(path: str) -> bool: | ||||
|     """Check if /auth/ UI path should be redirected to root on auth host.""" | ||||
|     if not path.startswith("/auth/"): | ||||
|         return False | ||||
|     ui_paths = {"/auth", "/auth/", "/auth/admin", "/auth/admin/"} | ||||
|     if path in ui_paths: | ||||
|         return True | ||||
|     # Check for reset token | ||||
|     token = path[6:] | ||||
|     return bool(token and "/" not in token and passphrase.is_well_formed(token)) | ||||
|  | ||||
|  | ||||
| def redirect_to_root_on_auth_host(request: Request, cur: str, path: str) -> Response: | ||||
|     """Create a redirect response to root path on the same host.""" | ||||
|     new_path = path[5:] or "/" | ||||
|     return RedirectResponse(f"{request.url.scheme}://{cur}{new_path}", 307) | ||||
|  | ||||
|  | ||||
| async def redirect_middleware(request: Request, call_next): | ||||
|     """Middleware to handle auth host redirects.""" | ||||
|     cfg = hostutil.configured_auth_host() | ||||
|     if not cfg: | ||||
|         return await call_next(request) | ||||
|  | ||||
|     cur = hostutil.normalize_host(request.headers.get("host")) | ||||
|     if not cur: | ||||
|         return await call_next(request) | ||||
|  | ||||
|     cfg_normalized = hostutil.normalize_host(cfg) | ||||
|     on_auth_host = cur == cfg_normalized | ||||
|  | ||||
|     path = request.url.path or "/" | ||||
|  | ||||
|     if not on_auth_host: | ||||
|         if not should_redirect_to_auth_host(path): | ||||
|             return await call_next(request) | ||||
|         return redirect_to_auth_host(request, cfg, path) | ||||
|     else: | ||||
|         # On auth host: force UI endpoints at root | ||||
|         if should_redirect_auth_path_to_root(path): | ||||
|             return redirect_to_root_on_auth_host(request, cur, path) | ||||
|         return await call_next(request) | ||||
| @@ -7,7 +7,12 @@ from ..util import permutil | ||||
| logger = logging.getLogger(__name__) | ||||
|  | ||||
|  | ||||
| async def verify(auth: str | None, perm: list[str], match=permutil.has_all): | ||||
| async def verify( | ||||
|     auth: str | None, | ||||
|     perm: list[str], | ||||
|     match=permutil.has_all, | ||||
|     host: str | None = None, | ||||
| ): | ||||
|     """Validate session token and optional list of required permissions. | ||||
|  | ||||
|     Returns the session context. | ||||
| @@ -19,7 +24,7 @@ async def verify(auth: str | None, perm: list[str], match=permutil.has_all): | ||||
|     if not auth: | ||||
|         raise HTTPException(status_code=401, detail="Authentication required") | ||||
|  | ||||
|     ctx = await permutil.session_context(auth) | ||||
|     ctx = await permutil.session_context(auth, host) | ||||
|     if not ctx: | ||||
|         raise HTTPException(status_code=401, detail="Session not found") | ||||
|  | ||||
|   | ||||
| @@ -2,13 +2,14 @@ import logging | ||||
| import os | ||||
| from contextlib import asynccontextmanager | ||||
|  | ||||
| from fastapi import Cookie, FastAPI, HTTPException | ||||
| from fastapi import FastAPI, HTTPException, Request, Response | ||||
| from fastapi.responses import FileResponse, RedirectResponse | ||||
| from fastapi.staticfiles import StaticFiles | ||||
|  | ||||
| from passkey.util import frontend, hostutil, passphrase | ||||
|  | ||||
| from . import admin, api, ws | ||||
| from . import admin, api, auth_host, ws | ||||
| from .session import AUTH_COOKIE | ||||
|  | ||||
|  | ||||
| @asynccontextmanager | ||||
| @@ -46,6 +47,10 @@ async def lifespan(app: FastAPI):  # pragma: no cover - startup path | ||||
|  | ||||
|  | ||||
| app = FastAPI(lifespan=lifespan) | ||||
|  | ||||
| # Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/) | ||||
| app.middleware("http")(auth_host.redirect_middleware) | ||||
|  | ||||
| app.mount("/auth/admin/", admin.app) | ||||
| app.mount("/auth/api/", api.app) | ||||
| app.mount("/auth/ws/", ws.app) | ||||
| @@ -59,8 +64,30 @@ app.mount( | ||||
|  | ||||
| @app.get("/") | ||||
| @app.get("/auth/") | ||||
| async def frontapp(): | ||||
|     return FileResponse(frontend.file("index.html")) | ||||
| async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): | ||||
|     """Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA. | ||||
|  | ||||
|     Login / authentication UX is centralized in the restricted app. | ||||
|     """ | ||||
|     if not auth: | ||||
|         return FileResponse(frontend.file("restricted", "index.html"), status_code=401) | ||||
|     from ..authsession import get_session  # local import | ||||
|  | ||||
|     try: | ||||
|         await get_session(auth, host=request.headers.get("host")) | ||||
|         cfg_host = hostutil.configured_auth_host() | ||||
|         if cfg_host: | ||||
|             cur_host = hostutil.normalize_host(request.headers.get("host")) | ||||
|             cfg_normalized = hostutil.normalize_host(cfg_host) | ||||
|             if cur_host and cfg_normalized and cur_host != cfg_normalized: | ||||
|                 return FileResponse(frontend.file("host", "index.html")) | ||||
|         return FileResponse(frontend.file("index.html")) | ||||
|     except Exception: | ||||
|         if auth: | ||||
|             from . import session as session_mod | ||||
|  | ||||
|             session_mod.clear_session_cookie(response) | ||||
|         return FileResponse(frontend.file("restricted", "index.html"), status_code=401) | ||||
|  | ||||
|  | ||||
| @app.get("/admin", include_in_schema=False) | ||||
| @@ -70,8 +97,8 @@ async def admin_root_redirect(): | ||||
|  | ||||
|  | ||||
| @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/ | ||||
| async def admin_root(request: Request, auth=AUTH_COOKIE): | ||||
|     return await admin.adminapp(request, auth)  # Delegated (enforces access control) | ||||
|  | ||||
|  | ||||
| @app.get("/{reset}") | ||||
|   | ||||
| @@ -63,11 +63,12 @@ async def _resolve_targets(query: str | None): | ||||
|  | ||||
| async def _create_reset(user, role_name: str): | ||||
|     token = passphrase.generate() | ||||
|     await _g.db.instance.create_session( | ||||
|     expiry = _authsession.reset_expires() | ||||
|     await _g.db.instance.create_reset_token( | ||||
|         user_uuid=user.uuid, | ||||
|         key=_tokens.reset_key(token), | ||||
|         expires=_authsession.expires(), | ||||
|         info={"type": "manual reset", "role": role_name}, | ||||
|         expiry=expiry, | ||||
|         token_type="manual reset", | ||||
|     ) | ||||
|     return hostutil.reset_link_url(token), token | ||||
|  | ||||
|   | ||||
| @@ -8,26 +8,45 @@ This module provides FastAPI-specific session management functionality: | ||||
| Generic session management functions have been moved to authsession.py | ||||
| """ | ||||
|  | ||||
| from fastapi import Request, Response, WebSocket | ||||
| from fastapi import Cookie, Request, Response, WebSocket | ||||
|  | ||||
| from ..authsession import EXPIRES | ||||
|  | ||||
| AUTH_COOKIE_NAME = "__Host-auth" | ||||
| AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME) | ||||
|  | ||||
|  | ||||
| def infodict(request: Request | WebSocket, type: str) -> dict: | ||||
|     """Extract client information from request.""" | ||||
|     return { | ||||
|         "ip": request.client.host if request.client else "", | ||||
|         "user_agent": request.headers.get("user-agent", "")[:500], | ||||
|         "type": type, | ||||
|         "ip": request.client.host if request.client else None, | ||||
|         "user_agent": request.headers.get("user-agent", "")[:500] or None, | ||||
|         "session_type": type, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def set_session_cookie(response: Response, token: str) -> None: | ||||
|     """Set the session token as an HTTP-only cookie.""" | ||||
|     response.set_cookie( | ||||
|         key="auth", | ||||
|         key=AUTH_COOKIE_NAME, | ||||
|         value=token, | ||||
|         max_age=int(EXPIRES.total_seconds()), | ||||
|         httponly=True, | ||||
|         secure=True, | ||||
|         path="/", | ||||
|         samesite="lax", | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def clear_session_cookie(response: Response) -> None: | ||||
|     # FastAPI's delete_cookie does not set the secure attribute | ||||
|     response.set_cookie( | ||||
|         key=AUTH_COOKIE_NAME, | ||||
|         value="", | ||||
|         max_age=0, | ||||
|         expires=0, | ||||
|         httponly=True, | ||||
|         secure=True, | ||||
|         path="/", | ||||
|         samesite="lax", | ||||
|     ) | ||||
|   | ||||
							
								
								
									
										136
									
								
								passkey/fastapi/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								passkey/fastapi/user.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| from datetime import timezone | ||||
| from uuid import UUID | ||||
|  | ||||
| from fastapi import ( | ||||
|     Body, | ||||
|     FastAPI, | ||||
|     HTTPException, | ||||
|     Request, | ||||
|     Response, | ||||
| ) | ||||
|  | ||||
| from ..authsession import ( | ||||
|     delete_credential, | ||||
|     expires, | ||||
|     get_session, | ||||
| ) | ||||
| from ..globals import db | ||||
| from ..util import hostutil, passphrase, tokens | ||||
| from ..util.tokens import decode_session_key, session_key | ||||
| from . import session | ||||
| from .session import AUTH_COOKIE | ||||
|  | ||||
| app = FastAPI() | ||||
|  | ||||
|  | ||||
| @app.put("/display-name") | ||||
| async def user_update_display_name( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     payload: dict = Body(...), | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     if not auth: | ||||
|         raise HTTPException(status_code=401, detail="Authentication Required") | ||||
|     try: | ||||
|         s = await get_session(auth, host=request.headers.get("host")) | ||||
|     except ValueError as e: | ||||
|         raise HTTPException(status_code=401, detail="Session expired") from e | ||||
|     new_name = (payload.get("display_name") or "").strip() | ||||
|     if not new_name: | ||||
|         raise HTTPException(status_code=400, detail="display_name required") | ||||
|     if len(new_name) > 64: | ||||
|         raise HTTPException(status_code=400, detail="display_name too long") | ||||
|     await db.instance.update_user_display_name(s.user_uuid, new_name) | ||||
|     return {"status": "ok"} | ||||
|  | ||||
|  | ||||
| @app.post("/logout-all") | ||||
| async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE): | ||||
|     if not auth: | ||||
|         return {"message": "Already logged out"} | ||||
|     try: | ||||
|         s = await get_session(auth, host=request.headers.get("host")) | ||||
|     except ValueError: | ||||
|         raise HTTPException(status_code=401, detail="Session expired") | ||||
|     await db.instance.delete_sessions_for_user(s.user_uuid) | ||||
|     session.clear_session_cookie(response) | ||||
|     return {"message": "Logged out from all hosts"} | ||||
|  | ||||
|  | ||||
| @app.delete("/session/{session_id}") | ||||
| async def api_delete_session( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     session_id: str, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     if not auth: | ||||
|         raise HTTPException(status_code=401, detail="Authentication Required") | ||||
|     try: | ||||
|         current_session = await get_session(auth, host=request.headers.get("host")) | ||||
|     except ValueError as exc: | ||||
|         raise HTTPException(status_code=401, detail="Session expired") from exc | ||||
|  | ||||
|     try: | ||||
|         target_key = decode_session_key(session_id) | ||||
|     except ValueError as exc: | ||||
|         raise HTTPException( | ||||
|             status_code=400, detail="Invalid session identifier" | ||||
|         ) from exc | ||||
|  | ||||
|     target_session = await db.instance.get_session(target_key) | ||||
|     if not target_session or target_session.user_uuid != current_session.user_uuid: | ||||
|         raise HTTPException(status_code=404, detail="Session not found") | ||||
|  | ||||
|     await db.instance.delete_session(target_key) | ||||
|     current_terminated = target_key == session_key(auth) | ||||
|     if current_terminated: | ||||
|         session.clear_session_cookie(response)  # explicit because 200 | ||||
|     return {"status": "ok", "current_session_terminated": current_terminated} | ||||
|  | ||||
|  | ||||
| @app.delete("/credential/{uuid}") | ||||
| async def api_delete_credential( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     uuid: UUID, | ||||
|     auth: str = AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         await delete_credential(uuid, auth, host=request.headers.get("host")) | ||||
|     except ValueError as e: | ||||
|         raise HTTPException(status_code=401, detail="Session expired") from e | ||||
|     return {"message": "Credential deleted successfully"} | ||||
|  | ||||
|  | ||||
| @app.post("/create-link") | ||||
| async def api_create_link( | ||||
|     request: Request, | ||||
|     response: Response, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     try: | ||||
|         s = await get_session(auth, host=request.headers.get("host")) | ||||
|     except ValueError as e: | ||||
|         raise HTTPException(status_code=401, detail="Session expired") from e | ||||
|     token = passphrase.generate() | ||||
|     expiry = expires() | ||||
|     await db.instance.create_reset_token( | ||||
|         user_uuid=s.user_uuid, | ||||
|         key=tokens.reset_key(token), | ||||
|         expiry=expiry, | ||||
|         token_type="device addition", | ||||
|     ) | ||||
|     url = hostutil.reset_link_url( | ||||
|         token, request.url.scheme, request.headers.get("host") | ||||
|     ) | ||||
|     return { | ||||
|         "message": "Registration link generated successfully", | ||||
|         "url": url, | ||||
|         "expires": ( | ||||
|             expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|             if expiry.tzinfo | ||||
|             else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z") | ||||
|         ), | ||||
|     } | ||||
| @@ -2,14 +2,14 @@ import logging | ||||
| from functools import wraps | ||||
| from uuid import UUID | ||||
|  | ||||
| from fastapi import Cookie, FastAPI, WebSocket, WebSocketDisconnect | ||||
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect | ||||
| from webauthn.helpers.exceptions import InvalidAuthenticationResponse | ||||
|  | ||||
| from ..authsession import create_session, expires, get_reset, get_session | ||||
| from ..authsession import create_session, get_reset, get_session | ||||
| from ..globals import db, passkey | ||||
| from ..util import passphrase | ||||
| from ..util.tokens import create_token, session_key | ||||
| from .session import infodict | ||||
| from .session import AUTH_COOKIE, infodict | ||||
|  | ||||
|  | ||||
| # WebSocket error handling decorator | ||||
| @@ -56,7 +56,10 @@ async def register_chat( | ||||
| @app.websocket("/register") | ||||
| @websocket_error_handler | ||||
| async def websocket_register_add( | ||||
|     ws: WebSocket, reset: str | None = None, name: str | None = None, auth=Cookie(None) | ||||
|     ws: WebSocket, | ||||
|     reset: str | None = None, | ||||
|     name: str | None = None, | ||||
|     auth=AUTH_COOKIE, | ||||
| ): | ||||
|     """Register a new credential for an existing user. | ||||
|  | ||||
| @@ -65,6 +68,7 @@ async def websocket_register_add( | ||||
|     - Reset token supplied as ?reset=... (auth cookie ignored) | ||||
|     """ | ||||
|     origin = ws.headers["origin"] | ||||
|     host = origin.split("://", 1)[1] | ||||
|     if reset is not None: | ||||
|         if not passphrase.is_well_formed(reset): | ||||
|             raise ValueError("Invalid reset token") | ||||
| @@ -72,7 +76,7 @@ async def websocket_register_add( | ||||
|     else: | ||||
|         if not auth: | ||||
|             raise ValueError("Authentication Required") | ||||
|         s = await get_session(auth) | ||||
|         s = await get_session(auth, host=host) | ||||
|     user_uuid = s.user_uuid | ||||
|  | ||||
|     # Get user information and determine effective user_name for this registration | ||||
| @@ -89,14 +93,16 @@ async def websocket_register_add( | ||||
|  | ||||
|     # Create a new session and store everything in database | ||||
|     token = create_token() | ||||
|     metadata = infodict(ws, "authenticated") | ||||
|     await db.instance.create_credential_session(  # type: ignore[attr-defined] | ||||
|         user_uuid=user_uuid, | ||||
|         credential=credential, | ||||
|         reset_key=(s.key if reset is not None else None), | ||||
|         session_key=session_key(token), | ||||
|         session_expires=expires(), | ||||
|         session_info=infodict(ws, "authenticated"), | ||||
|         display_name=user_name, | ||||
|         host=host, | ||||
|         ip=metadata.get("ip"), | ||||
|         user_agent=metadata.get("user_agent"), | ||||
|     ) | ||||
|     auth = token | ||||
|  | ||||
| @@ -115,6 +121,7 @@ async def websocket_register_add( | ||||
| @websocket_error_handler | ||||
| async def websocket_authenticate(ws: WebSocket): | ||||
|     origin = ws.headers["origin"] | ||||
|     host = origin.split("://", 1)[1] | ||||
|     options, challenge = passkey.instance.auth_generate_options() | ||||
|     await ws.send_json(options) | ||||
|     # Wait for the client to use his authenticator to authenticate | ||||
| @@ -128,10 +135,13 @@ async def websocket_authenticate(ws: WebSocket): | ||||
|  | ||||
|     # Create a session token for the authenticated user | ||||
|     assert stored_cred.uuid is not None | ||||
|     metadata = infodict(ws, "auth") | ||||
|     token = await create_session( | ||||
|         user_uuid=stored_cred.user_uuid, | ||||
|         info=infodict(ws, "auth"), | ||||
|         credential_uuid=stored_cred.uuid, | ||||
|         host=host, | ||||
|         ip=metadata.get("ip") or "", | ||||
|         user_agent=metadata.get("user_agent") or "", | ||||
|     ) | ||||
|  | ||||
|     await ws.send_json( | ||||
|   | ||||
| @@ -8,7 +8,7 @@ This module provides a unified interface for WebAuthn operations including: | ||||
| """ | ||||
|  | ||||
| import json | ||||
| from datetime import datetime | ||||
| from datetime import datetime, timezone | ||||
| from urllib.parse import urlparse | ||||
| from uuid import UUID | ||||
|  | ||||
| @@ -163,7 +163,7 @@ class Passkey: | ||||
|             aaguid=UUID(registration.aaguid), | ||||
|             public_key=registration.credential_public_key, | ||||
|             sign_count=registration.sign_count, | ||||
|             created_at=datetime.now(), | ||||
|             created_at=datetime.now(timezone.utc), | ||||
|         ) | ||||
|  | ||||
|     ### Authentication Methods ### | ||||
| @@ -227,7 +227,7 @@ class Passkey: | ||||
|             credential_current_sign_count=stored_cred.sign_count, | ||||
|         ) | ||||
|         stored_cred.sign_count = verification.new_sign_count | ||||
|         now = datetime.now() | ||||
|         now = datetime.now(timezone.utc) | ||||
|         stored_cred.last_used = now | ||||
|         if verification.user_verified: | ||||
|             stored_cred.last_verified = now | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| import os | ||||
| from functools import lru_cache | ||||
| from urllib.parse import urlparse | ||||
| from urllib.parse import urlparse, urlsplit | ||||
|  | ||||
| from ..globals import passkey as global_passkey | ||||
|  | ||||
| @@ -70,3 +70,23 @@ def reset_link_url( | ||||
|  | ||||
| def reload_config() -> None: | ||||
|     _load_config.cache_clear() | ||||
|  | ||||
|  | ||||
| def normalize_host(raw_host: str | None) -> str | None: | ||||
|     """Normalize a Host header preserving port (exact match required).""" | ||||
|     if not raw_host: | ||||
|         return None | ||||
|     candidate = raw_host.strip() | ||||
|     if not candidate: | ||||
|         return None | ||||
|     # urlsplit to parse (add // for scheme-less); prefer netloc to retain port. | ||||
|     parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}") | ||||
|     netloc = parsed.netloc or parsed.path or "" | ||||
|     # Strip IPv6 brackets around host part but retain port suffix. | ||||
|     if netloc.startswith("["): | ||||
|         # format: [ipv6]:port or [ipv6] | ||||
|         if "]" in netloc: | ||||
|             host_part, _, rest = netloc.partition("]") | ||||
|             port_part = rest.lstrip(":") | ||||
|             netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "") | ||||
|     return netloc.lower() or None | ||||
|   | ||||
| @@ -4,6 +4,7 @@ from collections.abc import Sequence | ||||
| from fnmatch import fnmatchcase | ||||
|  | ||||
| from ..globals import db | ||||
| from .hostutil import normalize_host | ||||
| from .tokens import session_key | ||||
|  | ||||
| __all__ = ["has_any", "has_all", "session_context"] | ||||
| @@ -24,5 +25,8 @@ def has_all(ctx, patterns: Sequence[str]) -> bool: | ||||
|     return all(_match(ctx.role.permissions, patterns)) if ctx else False | ||||
|  | ||||
|  | ||||
| async def session_context(auth: str | None): | ||||
|     return await db.instance.get_session_context(session_key(auth)) if auth else None | ||||
| async def session_context(auth: str | None, host: str | None = None): | ||||
|     if not auth: | ||||
|         return None | ||||
|     normalized_host = normalize_host(host) if host else None | ||||
|     return await db.instance.get_session_context(session_key(auth), normalized_host) | ||||
|   | ||||
| @@ -15,6 +15,25 @@ def session_key(token: str) -> bytes: | ||||
|     return b"sess" + base64.urlsafe_b64decode(token) | ||||
|  | ||||
|  | ||||
| def encode_session_key(key: bytes) -> str: | ||||
|     """Encode an opaque session key for external representation.""" | ||||
|     return base64.urlsafe_b64encode(key).decode().rstrip("=") | ||||
|  | ||||
|  | ||||
| def decode_session_key(encoded: str) -> bytes: | ||||
|     """Decode an opaque session key from its public representation.""" | ||||
|     if not encoded: | ||||
|         raise ValueError("Invalid session identifier") | ||||
|     padding = "=" * (-len(encoded) % 4) | ||||
|     try: | ||||
|         raw = base64.urlsafe_b64decode(encoded + padding) | ||||
|     except Exception as exc:  # pragma: no cover - defensive | ||||
|         raise ValueError("Invalid session identifier") from exc | ||||
|     if not raw.startswith(b"sess"): | ||||
|         raise ValueError("Invalid session identifier") | ||||
|     return raw | ||||
|  | ||||
|  | ||||
| def reset_key(passphrase: str) -> bytes: | ||||
|     if not is_well_formed(passphrase): | ||||
|         raise ValueError( | ||||
|   | ||||
							
								
								
									
										10
									
								
								passkey/util/useragent.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								passkey/util/useragent.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import user_agents | ||||
|  | ||||
|  | ||||
| def compact_user_agent(ua: str | None) -> str: | ||||
|     if not ua: | ||||
|         return "-" | ||||
|     u = user_agents.parse(ua) | ||||
|     ver = u.browser.version_string.split(".")[0] | ||||
|     dev = u.device.family if u.device.family not in ["Other", "Mac"] else "" | ||||
|     return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip() | ||||
| @@ -18,6 +18,7 @@ dependencies = [ | ||||
|     "aiosqlite>=0.19.0", | ||||
|     "uuid7-standard>=1.0.0", | ||||
|     "pyjwt>=2.8.0", | ||||
|     "user-agents>=2.2.0", | ||||
| ] | ||||
| requires-python = ">=3.10" | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user