Refactor validate endpoint to return session context, leaving user-info only for extra profile data. Completely separate token-info for reset tokens. Simplified by reusing same data structures in various places and mandating fields to have values not needing fallbacks. Implemented consistent AccessDenied view in profile and admin apps.

This commit is contained in:
2026-01-26 19:40:48 +00:00
parent fbc6108b7a
commit 7e568dbd10
12 changed files with 183 additions and 238 deletions
+21 -37
View File
@@ -2,10 +2,10 @@
<div class="app-shell">
<StatusMessage />
<main class="app-main">
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
<ProfileView v-else-if="authenticated" />
<LoadingView v-else-if="loading" :message="loadingMessage" />
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
<ProfileView v-else-if="viewState === 'profile'" />
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
<AccessDenied v-else-if="viewState === 'terminal'" />
</main>
</div>
</template>
@@ -18,13 +18,11 @@ import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AccessDenied.vue'
import AccessDenied from '@/components/AccessDenied.vue'
const store = useAuthStore()
const loading = ref(true)
const viewState = ref('loading') // 'loading' | 'profile' | 'terminal'
const loadingMessage = ref('Loading...')
const authenticated = ref(false)
const showBackMessage = ref(false)
/**
* Normalize a host string for comparison (lowercase, strip default ports).
@@ -51,14 +49,19 @@ const isHostMode = computed(() => {
let validationTimer = null
let authIframe = null
function terminateSession() {
store.userInfo = null
viewState.value = 'terminal'
}
async function loadUserInfo() {
try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
authenticated.value = true
loading.value = false
viewState.value = 'profile'
startSessionValidation()
return true
} catch (e) {
} catch {
store.userInfo = null
return false
}
}
@@ -85,10 +88,6 @@ function hideAuthIframe() {
}
}
function reloadPage() {
window.location.reload()
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
@@ -97,7 +96,7 @@ function handleAuthMessage(event) {
case 'auth-success':
// Authentication successful - reload user info
hideAuthIframe()
loading.value = true
viewState.value = 'loading'
loadingMessage.value = 'Loading user profile...'
loadUserInfo()
break
@@ -117,11 +116,9 @@ function handleAuthMessage(event) {
break
case 'auth-back':
// User clicked Back - show message with reload option
// User clicked Back - show terminal state
hideAuthIframe()
loading.value = false
showBackMessage.value = true
store.showMessage('Authentication cancelled', 'info', 3000)
terminateSession()
break
case 'auth-close-request':
@@ -133,23 +130,10 @@ function handleAuthMessage(event) {
async function validateSession() {
try {
await apiJson('/auth/api/validate', {
method: 'POST',
credentials: 'include'
})
// If successful, session was renewed automatically
} catch (error) {
if (error.status === 401) {
// Session expired - need to re-authenticate
console.log('Session expired, requiring re-authentication')
authenticated.value = false
loading.value = true
stopSessionValidation()
showAuthIframe()
} else {
console.error('Session validation error:', error)
// Don't treat network errors as session expiry
}
await apiJson('/auth/api/validate', { method: 'POST' })
} catch {
stopSessionValidation()
terminateSession()
}
}
+29 -34
View File
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue'
import StatusMessage from '@/components/StatusMessage.vue'
import LoadingView from '@/components/LoadingView.vue'
import AuthRequiredMessage from '@/components/AccessDenied.vue'
import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
@@ -48,8 +48,8 @@ const adminUserDetailRef = ref(null)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
// Derive admin status from permissions
const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false)
const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false)
const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin'))
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
@@ -144,10 +144,19 @@ async function loadPermissions() {
}
async function loadUserInfo() {
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
const data = await apiJson('/auth/api/validate', { method: 'POST' })
info.value = data
authenticated.value = true
}
function clearSensitiveState() {
info.value = null
orgs.value = []
permissions.value = []
userDetail.value = null
authenticated.value = false
}
async function load() {
loading.value = true
loadingMessage.value = 'Loading...'
@@ -158,7 +167,7 @@ async function load() {
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
if (!isGlobalAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
if (!window.location.hash || window.location.hash === '#overview') {
currentOrgId.value = orgs.value[0].uuid
window.location.hash = `#org/${currentOrgId.value}`
@@ -168,6 +177,7 @@ async function load() {
}
} else parseHash()
} catch (e) {
clearSensitiveState()
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
@@ -191,8 +201,6 @@ async function performOrgDeletion(orgUuid) {
}
function deleteOrg(org) {
if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return }
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
if (userCount === 0) {
@@ -333,10 +341,6 @@ function deletePermission(p) {
} })
}
function reloadPage() {
window.location.reload()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) {
@@ -384,7 +388,7 @@ const breadcrumbEntries = computed(() => {
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
}
return entries
})
@@ -703,23 +707,19 @@ async function submitDialog() {
<StatusMessage />
<main class="app-main">
<LoadingView v-if="loading" :message="loadingMessage" />
<AuthRequiredMessage
v-else-if="showBackMessage"
@reload="reloadPage"
<AccessDenied v-else-if="showBackMessage" />
<AccessDenied
v-else-if="error"
icon="⚠️"
title="Error"
:message="error"
/>
<!-- Access denied: authenticated but not admin, or error occurred -->
<div v-else-if="error || (authenticated && !isGlobalAdmin && !isOrgAdmin)" class="access-denied-container">
<div class="access-denied-content">
<h2> Access Denied</h2>
<p v-if="error" class="error-detail">{{ error }}</p>
<p v-else class="error-detail">You do not have admin permissions for this application.</p>
<div class="button-row">
<button class="btn-secondary" @click="goBack">Back</button>
<button class="btn-primary" @click="reloadPage">Reload Page</button>
</div>
</div>
</div>
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<AccessDenied
v-else-if="authenticated && !isMasterAdmin && !isOrgAdmin"
icon="⛔"
message="You do not have admin permissions for this application."
/>
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header">
<h1>{{ pageHeading }}</h1>
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
@@ -729,7 +729,7 @@ async function submitDialog() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
@@ -805,9 +805,4 @@ async function submitDialog() {
.admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
.access-denied-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem; }
.access-denied-content { text-align: center; max-width: 480px; }
.access-denied-content h2 { margin: 0 0 1rem; color: var(--color-heading); font-size: 1.5rem; }
.access-denied-content .error-detail { margin: 0 0 1.5rem; color: var(--color-text-muted); }
.access-denied-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
</style>
+10 -9
View File
@@ -71,12 +71,12 @@ const initializing = ref(true)
const loading = ref(false)
const token = ref('')
const settings = ref(null)
const userInfo = ref(null)
const tokenInfo = ref(null)
const displayName = ref('')
const errorMessage = ref('')
let statusTimer = null
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your enrollment')
const subtitleMessage = computed(() => {
if (initializing.value) return 'Preparing your secure enrollment…'
if (!canRegister.value) return 'This authentication link is no longer valid.'
@@ -85,7 +85,7 @@ const subtitleMessage = computed(() => {
const basePath = computed(() => uiBasePath())
const canRegister = computed(() => !!(token.value && userInfo.value))
const canRegister = computed(() => !!(token.value && tokenInfo.value))
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
@@ -109,15 +109,16 @@ async function fetchSettings() {
}
}
async function fetchUserInfo() {
async function fetchTokenInfo() {
if (!token.value) return
try {
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
method: 'POST'
tokenInfo.value = await apiJson('/auth/api/token-info', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token.value}` },
})
displayName.value = userInfo.value?.user?.user_name || ''
displayName.value = tokenInfo.value.display_name
} catch (error) {
console.error('Failed to load user info', error)
console.error('Failed to load token info', error)
const message = error instanceof ApiError
? (error.data?.detail || 'The authentication link is invalid or expired.')
: getUserFriendlyErrorMessage(error)
@@ -196,7 +197,7 @@ onMounted(async () => {
initializing.value = false
return
}
await fetchUserInfo()
await fetchTokenInfo()
initializing.value = false
})
</script>
+12 -12
View File
@@ -26,9 +26,9 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
}))
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions
const isGlobalAdmin = computed(() => props.info?.permissions?.includes('auth:admin') ?? false)
const isOrgAdmin = computed(() => props.info?.permissions?.includes('auth:org:admin') ?? false)
// Derive admin status from permissions (info contains ctx from validate response)
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
@@ -93,7 +93,7 @@ function handleTableKeydown(event, tableType) {
} else if (direction === 'down' && currentIndex === rows.length - 1) {
// At bottom of org table, navigate to permissions section
event.preventDefault()
if (tableType === 'org' && isGlobalAdmin.value) {
if (tableType === 'org' && isMasterAdmin.value) {
// Navigate to permissions matrix or actions
if (permMatrixRef.value) {
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
@@ -236,7 +236,7 @@ function handlePermActionsKeydown(event) {
// Focus helper for external navigation
function focusFirstElement() {
if (isGlobalAdmin.value) {
if (isMasterAdmin.value) {
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
} else {
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
@@ -249,9 +249,9 @@ defineExpose({ focusFirstElement })
<template>
<div class="permissions-section" ref="orgSection">
<h2>{{ isGlobalAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
<h2>{{ isMasterAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
<button v-if="isGlobalAdmin" @click="$emit('createOrg')">+ Create Org</button>
<button v-if="isMasterAdmin" @click="$emit('createOrg')">+ Create Org</button>
</div>
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
<thead>
@@ -259,18 +259,18 @@ defineExpose({ focusFirstElement })
<th>Name</th>
<th>Roles</th>
<th>Members</th>
<th v-if="isGlobalAdmin">Actions</th>
<th v-if="isMasterAdmin">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="o in sortedOrgs" :key="o.uuid">
<td>
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
<button v-if="isGlobalAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization"></button>
</td>
<td class="role-names">{{ getRoleNames(o) }}</td>
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
<td v-if="isGlobalAdmin" class="center">
<td v-if="isMasterAdmin" class="center">
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization"></button>
</td>
</tr>
@@ -278,7 +278,7 @@ defineExpose({ focusFirstElement })
</table>
</div>
<div v-if="isGlobalAdmin" class="permissions-section">
<div v-if="isMasterAdmin" class="permissions-section">
<h2>Permissions</h2>
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
<div class="matrix-scroll">
@@ -317,7 +317,7 @@ defineExpose({ focusFirstElement })
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
</div>
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
<button v-if="isGlobalAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
<button v-if="isMasterAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
</div>
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
<thead>
+18 -4
View File
@@ -1,10 +1,11 @@
<template>
<div class="message-container">
<div class="message-content">
<h2>🔒 Access Denied</h2>
<h2>{{ icon }} {{ title }}</h2>
<p v-if="message" class="error-detail">{{ message }}</p>
<div class="button-row">
<button class="btn-secondary" @click="goBack">Back</button>
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
<button class="btn-primary" @click="reload">Reload Page</button>
</div>
</div>
</div>
@@ -13,7 +14,15 @@
<script setup>
import { goBack } from '@/utils/helpers'
defineEmits(['reload'])
const props = defineProps({
title: { type: String, default: 'Access Denied' },
icon: { type: String, default: '🔒' },
message: { type: String, default: null },
})
function reload() {
window.location.reload()
}
</script>
<style scoped>
@@ -32,10 +41,15 @@ defineEmits(['reload'])
}
.message-content h2 {
margin: 0 0 1.5rem;
margin: 0 0 1rem;
color: var(--color-heading);
}
.message-content .error-detail {
margin: 0 0 1.5rem;
color: var(--color-text-muted);
}
.message-content .button-row {
display: flex;
gap: 0.75rem;
+8 -8
View File
@@ -8,11 +8,11 @@
<section class="section-block" ref="userInfoSection">
<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"
v-if="ctx"
:name="ctx.user.display_name"
:visits="authStore.userInfo?.visits || 0"
:created-at="authStore.userInfo?.created_at"
:last-seen="authStore.userInfo?.last_seen"
:org-display-name="orgDisplayName"
:role-name="roleDisplayName"
:can-edit="false"
@@ -78,9 +78,9 @@ const currentHost = window.location.host
const userInfoSection = ref(null)
const buttonRow = ref(null)
const user = computed(() => authStore.userInfo?.user || null)
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
const ctx = computed(() => authStore.userInfo?.ctx || null)
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
const headingTitle = computed(() => {
const service = authStore.settings?.rp_name
+8 -8
View File
@@ -8,12 +8,12 @@
<section class="section-block" ref="userInfoSection">
<UserBasicInfo
v-if="authStore.userInfo?.user"
v-if="authStore.userInfo?.ctx"
ref="userBasicInfo"
:name="authStore.userInfo.user.user_name"
:visits="authStore.userInfo.user.visits || 0"
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:name="authStore.userInfo.ctx.user.display_name"
:visits="authStore.userInfo.visits"
:created-at="authStore.userInfo.created_at"
:last-seen="authStore.userInfo.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
@@ -151,7 +151,7 @@ const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
@@ -323,9 +323,9 @@ const terminateSession = async (session) => {
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 openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
const isAdmin = computed(() => {
const perms = authStore.userInfo?.permissions ?? []
const perms = authStore.userInfo?.ctx.permissions
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
+10 -11
View File
@@ -76,13 +76,13 @@ 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 session = ref(null)
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
const authView = ref('local') // 'local' or 'remote'
const buttonRow = ref(null)
let statusTimer = null
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
const isAuthenticated = computed(() => !!session.value)
const canAuthenticate = computed(() => {
if (initializing.value) return false
@@ -115,7 +115,7 @@ const headerMessage = computed(() => {
return 'Please sign in with your passkey.'
})
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
const userDisplayName = computed(() => session.value?.ctx.user.display_name || 'User')
function showMessage(message, type = 'info', duration = 3000) {
status.show = true
@@ -140,22 +140,21 @@ async function fetchSettings() {
}
}
async function fetchUserInfo() {
async function validateSession() {
try {
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden'
emit('forbidden', userInfo.value)
emit('forbidden', session.value)
} else {
currentView.value = 'login'
}
} catch (error) {
console.error('Failed to load user info', error)
session.value = null
currentView.value = 'login'
if (error.status !== 401 && error.status !== 403) {
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
}
userInfo.value = null
currentView.value = 'login'
}
}
@@ -188,7 +187,7 @@ async function logoutUser() {
loading.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
userInfo.value = null
session.value = null
currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
} catch (error) {
@@ -266,7 +265,7 @@ watch(initializing, (newVal) => {
onMounted(async () => {
await fetchSettings()
await fetchUserInfo()
await validateSession()
initializing.value = false
// Add click handler for inline links
+11 -7
View File
@@ -485,11 +485,11 @@ def get_session_context(
user = build_user(s.user)
role = build_role(role_uuid)
org = build_org(org_uuid)
credential = (
build_credential(s.credential)
if s.credential in _db._data.credentials
else None
)
# Credential must exist (sessions are cascade-deleted when credential is deleted)
if s.credential not in _db._data.credentials:
return None
credential = build_credential(s.credential)
# Effective permissions: role's permissions that the org can grant
# Also filter by domain if host is provided
@@ -516,7 +516,7 @@ def get_session_context(
org=org,
role=role,
credential=credential,
permissions=effective_perms or None,
permissions=effective_perms,
)
@@ -956,7 +956,7 @@ def delete_credential(
*,
ctx: SessionContext | None = None,
) -> None:
"""Delete a credential.
"""Delete a credential and all sessions using it.
If user_uuid is provided, validates that the credential belongs to that user.
"""
@@ -971,6 +971,10 @@ def delete_credential(
if cred_user != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("Deleted credential", ctx):
# Delete all sessions using this credential
keys = [k for k, s in _db._data.sessions.items() if s.credential == uuid]
for k in keys:
del _db._data.sessions[k]
del _db._data.credentials[uuid]
+2 -2
View File
@@ -76,8 +76,8 @@ class SessionContext(msgspec.Struct):
user: User
org: Org
role: Role
credential: Credential | None = None
permissions: list[Permission] | None = None
credential: Credential
permissions: list[Permission] = []
# -------------------------------------------------------------------------
+29 -41
View File
@@ -78,12 +78,7 @@ async def validate_token(
max_age: str | None = Query(None),
auth=AUTH_COOKIE,
):
"""Validate the current session and extend its expiry.
Always refreshes the session (sliding expiration) and re-sets the cookie with a
renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint.
"""
"""Validate session and return context. Refreshes session expiry."""
try:
ctx = await authz.verify(
auth,
@@ -113,8 +108,26 @@ async def validate_token(
)
return {
"valid": True,
"user_uuid": str(ctx.session.user_uuid),
"renewed": renewed,
"ctx": userinfo.format_session_context(ctx),
}
@app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header."""
token = credentials.credentials
if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format")
try:
reset_token = await get_reset(token)
except ValueError as e:
raise HTTPException(401, str(e))
u = db.get_user_by_uuid(reset_token.user_uuid)
return {
"token_type": reset_token.token_type,
"display_name": u.display_name,
}
@@ -236,47 +249,22 @@ async def api_token_info(token: str):
async def api_user_info(
request: Request,
response: Response,
reset: str | None = None,
auth=AUTH_COOKIE,
):
"""Get user information including credentials, sessions, and permissions.
Can be called with either:
- A session cookie (auth) for authenticated users
- A reset token for users in password reset flow
"""
authenticated = False
session_record = None
reset_token = None
"""Get full user profile including credentials and sessions."""
if auth is None:
raise authz.AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
try:
if reset:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
reset_token = await get_reset(reset)
target_user_uuid = reset_token.user_uuid
else:
if auth is None:
raise authz.AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
session_record = await get_session(auth, host=request.headers.get("host"))
authenticated = True
target_user_uuid = session_record.user_uuid
session_record = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(401, str(e))
# Return minimal response for reset tokens
if not authenticated and reset_token:
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
# Return full user info for authenticated users
assert auth is not None
assert session_record is not None
return await userinfo.format_user_info(
user_uuid=target_user_uuid,
user_uuid=session_record.user_uuid,
auth=auth,
session_record=session_record,
request_host=request.headers.get("host"),
+25 -65
View File
@@ -4,6 +4,7 @@ from datetime import timezone
from paskia import aaguid, db
from paskia.authsession import EXPIRES
from paskia.db import SessionContext
from paskia.util import hostutil, permutil, useragent
@@ -17,6 +18,25 @@ def _format_datetime(dt):
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
def format_session_context(ctx: SessionContext) -> dict:
"""Format SessionContext for JSON response."""
return {
"user": {
"uuid": str(ctx.user.uuid),
"display_name": ctx.user.display_name,
},
"org": {
"uuid": str(ctx.org.uuid),
"display_name": ctx.org.display_name,
},
"role": {
"uuid": str(ctx.role.uuid),
"display_name": ctx.role.display_name,
},
"permissions": [p.scope for p in ctx.permissions],
}
async def format_user_info(
*,
user_uuid,
@@ -24,23 +44,7 @@ async def format_user_info(
session_record,
request_host: str | None,
) -> dict:
"""Format complete user information for authenticated users.
Args:
user_uuid: UUID of the user to fetch information for
auth: Authentication token
session_record: Current session record
request_host: Host header from the request
Returns:
Dictionary containing formatted user information including:
- User details
- Organization and role information
- Credentials list
- Sessions list
- Permissions
"""
u = db.get_user_by_uuid(user_uuid)
"""Format complete user information for authenticated users."""
ctx = await permutil.session_context(auth, request_host)
# Fetch and format credentials
@@ -66,24 +70,6 @@ async def format_user_info(
credentials.sort(key=lambda cred: cred["created_at"])
aaguid_info = aaguid.filter(user_aaguids)
# Format role and org information
role_info = None
org_info = None
effective_permissions: list[str] = []
if ctx:
role_info = {
"uuid": str(ctx.role.uuid),
"display_name": ctx.role.display_name,
"permissions": ctx.role.permissions,
}
org_info = {
"uuid": str(ctx.org.uuid),
"display_name": ctx.org.display_name,
"permissions": ctx.org.permissions,
}
effective_permissions = [p.scope for p in (ctx.permissions or [])]
# Format sessions
normalized_request_host = hostutil.normalize_host(request_host)
session_records = db.list_sessions_for_user(user_uuid)
@@ -109,37 +95,11 @@ async def format_user_info(
)
return {
"authenticated": True,
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": _format_datetime(u.created_at),
"last_seen": _format_datetime(u.last_seen),
"visits": u.visits,
},
"org": org_info,
"role": role_info,
"permissions": effective_permissions,
"ctx": format_session_context(ctx),
"created_at": _format_datetime(ctx.user.created_at),
"last_seen": _format_datetime(ctx.user.last_seen),
"visits": ctx.user.visits,
"credentials": credentials,
"aaguid_info": aaguid_info,
"sessions": sessions_payload,
}
async def format_reset_user_info(user_uuid, reset_token) -> dict:
"""Format minimal user information for reset token requests.
Args:
user_uuid: UUID of the user
reset_token: Reset token record
Returns:
Dictionary with minimal user info for password reset flow
"""
u = db.get_user_by_uuid(user_uuid)
return {
"authenticated": False,
"session_type": reset_token.token_type,
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
}