OAuth2 OpenID Connect provider support, API and DB refactoring (#3)

Allows Paskia to authenticate the user to a client site.
- User friendly client registration flow on the admin app
- Redirect-based authentication flow (per spec)
- Backchannel logout both ways to keep sessions synchronized
- Groups integrated with Paskia's permission system
- Adds email, preferred username and telephone fields on user profile
- All new user basic info layout to show the new information, better looks
- API and DB structures redesigned
- Various unrelated fixes to theming and layout
This commit was merged in pull request #3.
This commit is contained in:
2026-02-18 02:40:27 +00:00
parent ccf2deee95
commit 4604a65646
71 changed files with 3706 additions and 805 deletions
+1 -2
View File
@@ -47,7 +47,6 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost
})
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
function terminateSession() {
store.userInfo = null
@@ -64,7 +63,7 @@ async function loadUserInfo() {
try {
const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }),
apiJson('/auth/api/user-info', { method: 'POST' })
apiJson('/auth/api/user-info', { method: 'GET' })
])
store.userInfo = userInfoData
store.ctx = validateData.ctx
+235 -60
View File
@@ -9,10 +9,12 @@ import AccessDenied from '@/components/AccessDenied.vue'
import AdminOverview from '@/admin/AdminOverview.vue'
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia'
import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
@@ -24,9 +26,12 @@ const showBackMessage = ref(false)
const error = ref(null)
const orgs = ref([])
const permissions = ref([])
const oidcClients = ref([])
const currentOrgId = ref(null) // UUID of selected org for detail view
const currentUserId = ref(null) // UUID for user detail view
const currentOidcId = ref(null) // UUID for OIDC client detail view
const userDetail = ref(null) // cached user detail object
const editingOidcClient = ref(null) // OIDC client being edited (with local changes)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
@@ -43,6 +48,7 @@ const breadcrumbsRef = ref(null)
const adminOverviewRef = ref(null)
const adminOrgDetailRef = ref(null)
const adminUserDetailRef = ref(null)
const adminOidcDetailRef = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
@@ -80,10 +86,11 @@ const permissionSummary = computed(() => {
const summary = {}
for (const o of orgs.value) {
const orgBase = { uuid: o.uuid, display_name: o.org.display_name }
const orgPerms = new Set(Object.keys(o.permissions))
// o.permissions is a dict[UUID, Permission]
const orgPermUuids = new Set(Object.keys(o.permissions || {}))
// Org-level permissions (direct) - only count if org can grant them
for (const pid of Object.keys(o.permissions)) {
for (const pid of Object.keys(o.permissions || {})) {
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
summary[pid].orgs.push(orgBase)
@@ -92,10 +99,11 @@ const permissionSummary = computed(() => {
}
// Role-based permissions (inheritance) - only count if org can grant them
for (const [roleUuid, r] of Object.entries(o.roles)) {
for (const [roleUuid, r] of Object.entries(o.roles || {})) {
// r.permissions is dict[UUID, bool]
for (const pid of Object.keys(r.permissions || {})) {
// Only count if the org can grant this permission
if (!orgPerms.has(pid)) continue
if (!orgPermUuids.has(pid)) continue
if (!summary[pid]) summary[pid] = { orgs: [], orgSet: new Set(), userCount: 0 }
if (!summary[pid].orgSet.has(o.uuid)) {
@@ -120,16 +128,49 @@ function parseHash() {
const h = window.location.hash || ''
currentOrgId.value = null
currentUserId.value = null
currentOidcId.value = null
editingOidcClient.value = null
if (h.startsWith('#org/')) {
currentOrgId.value = h.slice(5)
} else if (h.startsWith('#user/')) {
currentUserId.value = h.slice(6)
} else if (h.startsWith('#oidc:')) {
const oidcUuid = h.slice(6)
currentOidcId.value = oidcUuid
// Initialize editing client data
if (oidcUuid === 'new') {
// Generate client_id and secret for new client
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
editingOidcClient.value = {
client_id: uuidv7(),
client_secret,
isNew: true,
name: '',
redirect_uris: []
}
} else {
const client = oidcClients.value.find(c => c.uuid === oidcUuid)
if (client) {
editingOidcClient.value = {
...client,
client_id: client.uuid,
client_secret: null,
isNew: false
}
}
}
}
}
async function loadOrgs() {
const data = await apiJson('/auth/api/admin/orgs')
orgs.value = Object.entries(data).map(([uuid, o]) => ({ uuid, ...o }))
async function loadAdminData() {
const data = await apiJson('/auth/api/admin/info')
// Convert dicts to arrays with uuid added
orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o }))
permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p }))
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
}
// Helper to get users for a role as sorted array of [uuid, user]
@@ -153,10 +194,6 @@ function orgUserCount(org) {
return Object.keys(org.users).length
}
async function loadPermissions() {
permissions.value = Object.values(await apiJson('/auth/api/admin/permissions'))
}
async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
info.value = data
@@ -167,7 +204,9 @@ function clearSensitiveState() {
info.value = null
orgs.value = []
permissions.value = []
oidcClients.value = []
userDetail.value = null
editingOidcClient.value = null
authenticated.value = false
}
@@ -192,7 +231,7 @@ async function load() {
error.value = null
try {
// Load admin data first - apiJson will handle 401/403 with iframe authentication
await Promise.all([loadOrgs(), loadPermissions()])
await loadAdminData()
// If we get here, user has admin access - now fetch user info for display
await loadUserInfo()
@@ -214,13 +253,13 @@ async function load() {
// Org actions
function createOrg() { openDialog('org-create', {}) }
function updateOrg(org) { openDialog('org-update', { org, name: org.org.display_name }) }
function updateOrg(org) { openDialog('org-update', { org, name: org.display_name }) }
function editUserName(user) { openDialog('user-update-name', { user, name: user.display_name }) }
async function performOrgDeletion(orgUuid) {
await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' })
await Promise.all([loadOrgs(), loadPermissions()])
await Promise.all([loadAdminData()])
}
function deleteOrg(org) {
@@ -240,9 +279,9 @@ function deleteOrg(org) {
// Build detailed breakdown of users by role
const roleParts = Object.entries(org.roles)
.map(([uuid, r]) => [roleUserCount(org, uuid), r.display_name])
.filter(([count]) => count > 0)
.map(([count, name]) => `${count} ${name}`)
.map(([uuid, r]) => ({ role: r, count: roleUserCount(org, uuid) }))
.filter(x => x.count > 0)
.map(x => `${x.count} ${x.role.display_name}`)
const affects = roleParts.join(', ')
@@ -254,7 +293,7 @@ function deleteOrg(org) {
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
function deleteUser(user, userDetail) {
const credentialCount = Object.keys(userDetail?.credentials || {}).length
const credentialCount = userDetail?.credentials ? Object.keys(userDetail.credentials).length : 0
const userUuid = user.uuid
const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser
@@ -278,7 +317,7 @@ async function performUserDeletion(userUuid, userName, orgUuid) {
try {
await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
await loadOrgs()
await loadAdminData()
window.location.hash = `#org/${orgUuid}`
} catch (e) {
authStore.showMessage(e.message || 'Failed to delete user', 'error')
@@ -292,7 +331,7 @@ async function moveUserToRole(userUuid, user, targetRoleUuid) {
method: 'PATCH',
body: { role_uuid: targetRoleUuid }
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update user role')
}
@@ -308,13 +347,13 @@ function onRoleDragOver(e) {
e.dataTransfer.dropEffect = 'move'
}
function onRoleDrop(e, org, roleUuid) {
function onRoleDrop(e, org, role) {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org
const user = org.users[data.user_uuid]
if (user) moveUserToRole(data.user_uuid, user, roleUuid)
if (user) moveUserToRole(data.user_uuid, user, role.uuid)
} catch (_) { /* ignore */ }
}
@@ -323,22 +362,22 @@ function createRole(org) { openDialog('role-create', { org }) }
function updateRole(role) { openDialog('role-update', { role, name: role.display_name }) }
function deleteRole(roleUuid, role) {
function deleteRole(role) {
// UI only allows deleting empty roles, so no confirmation needed
apiJson(`/auth/api/admin/roles/${roleUuid}`, { method: 'DELETE' })
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
.then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to delete role', 'error')
})
}
async function toggleRolePermission(roleUuid, role, pid, checked) {
// Optimistic update
const prevPermissions = { ...(role.permissions || {}) }
const newPermissions = { ...(role.permissions || {}) }
async function toggleRolePermission(role, pid, checked) {
// Optimistic update - role.permissions is dict[UUID, bool]
const prevPermissions = { ...role.permissions }
const newPermissions = { ...role.permissions }
if (checked) {
newPermissions[pid] = true
} else {
@@ -348,10 +387,10 @@ async function toggleRolePermission(roleUuid, role, pid, checked) {
try {
const method = checked ? 'POST' : 'DELETE'
await apiJson(`/auth/api/admin/roles/${roleUuid}/permissions/${pid}`, {
await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
method
})
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update role permission')
role.permissions = prevPermissions // revert
@@ -362,7 +401,7 @@ async function toggleRolePermission(roleUuid, role, pid, checked) {
async function performPermissionDeletion(permissionUuid) {
const params = new URLSearchParams({ permission_uuid: permissionUuid })
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
await loadPermissions()
await loadAdminData()
}
function deletePermission(p) {
@@ -400,6 +439,87 @@ function deletePermission(p) {
} })
}
// OIDC Client actions
async function sha256Hex(text) {
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
}
function createOidcClient() {
// Navigate to new OIDC client page
window.location.hash = '#oidc:new'
}
function openOidcClient(client) {
// Navigate to OIDC client detail page
window.location.hash = `#oidc:${client.uuid}`
}
function resetOidcSecret(clientId) {
// Generate new secret locally; it will be sent to server on Save
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
const client_secret = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
// Update editingOidcClient if we're on the detail page
if (editingOidcClient.value?.client_id === clientId) {
editingOidcClient.value = { ...editingOidcClient.value, client_secret }
}
// Also update dialog if open (for backwards compatibility)
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
dialog.value.data.client_secret = client_secret
}
}
function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
function deleteOidcClient(client) {
openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
action: async () => {
await performOidcClientDeletion(client.uuid, client.name)
// Navigate back to overview if we were on the detail page
if (currentOidcId.value === client.uuid) {
window.location.hash = '#overview'
}
}
})
}
async function performOidcClientDeletion(clientUuid, clientName) {
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
await loadAdminData()
}
async function handleOidcSave(data) {
const { client_id, client_secret, name, redirect_uris, isNew } = data
try {
if (client_secret) {
const secret_hash = await sha256Hex(client_secret)
if (isNew) {
await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })
}
} else {
await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
}
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
await loadAdminData()
window.location.hash = '#overview'
} catch (e) {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}
}
function handleOidcCancel() {
goOverview()
}
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
function openOrg(o) {
@@ -431,21 +551,27 @@ const breadcrumbEntries = computed(() => {
const entries = [
{ label: 'My Profile', href: makeUiHref() }
]
// For org admins, combine Admin and their org
if (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) {
const org = orgs.value[0]
entries.push({ label: `Admin: ${org.org.display_name}`, href: `#org/${org.uuid}` })
} else {
entries.push({ label: 'Admin', href: adminUiPath() })
}
// Determine organization for user view if selectedOrg not explicitly chosen.
let orgForUser = null
if (selectedUser.value) {
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
}
const orgToShow = selectedOrg.value || orgForUser
if (orgToShow && isOrgAdmin.value && !isMasterAdmin.value) {
// For org admins, combine Admin and org name into one link
entries.push({ label: `Admin: ${orgToShow.org.display_name}`, href: `#org/${orgToShow.uuid}` })
} else {
// For master admins or when not showing an org, separate Admin link
entries.push({ label: 'Admin', href: isMasterAdmin.value ? adminUiPath() : (orgs.value.length === 1 ? `#org/${orgs.value[0].uuid}` : adminUiPath()) })
if (orgToShow) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
// Add org breadcrumb only if it's not already included in the Admin entry
const adminOrg = (isOrgAdmin.value && !isMasterAdmin.value && orgs.value.length > 0) ? orgs.value[0] : null
if (orgToShow && (!adminOrg || orgToShow.uuid !== adminOrg.uuid)) {
entries.push({ label: orgToShow.org.display_name, href: `#org/${orgToShow.uuid}` })
}
if (currentOidcId.value) {
const label = editingOidcClient.value?.isNew ? 'New Client' : (editingOidcClient.value?.name || 'OIDC Client')
entries.push({ label, href: `#oidc:${currentOidcId.value}` })
}
if (selectedUser.value) {
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
@@ -468,23 +594,27 @@ function generateUserRegistrationLink(u) {
}
async function toggleOrgPermission(org, permId, checked) {
// Build next permission dict
// org.permissions is dict[UUID, Permission]
const has = permId in org.permissions
if (checked && has) return
if (!checked && !has) return
const next = { ...org.permissions }
if (checked) {
next[permId] = true // Placeholder, real data comes from loadOrgs
} else {
delete next[permId]
}
// Optimistic update
const prev = { ...org.permissions }
org.permissions = next
if (checked) {
// Need to fetch the permission object to add it
const perm = permissions.value.find(p => p.uuid === permId)
if (perm) {
org.permissions = { ...org.permissions, [permId]: perm }
}
} else {
const next = { ...org.permissions }
delete next[permId]
org.permissions = next
}
try {
const params = new URLSearchParams({ permission_uuid: permId })
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs()
await loadAdminData()
} catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert
@@ -594,7 +724,7 @@ function handlePanelNavigateOut(direction) {
}
async function refreshUserDetail() {
await loadOrgs()
await loadAdminData()
if (selectedUser.value) {
try {
userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
@@ -620,7 +750,7 @@ async function submitDialog() {
apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Organization "${name}" created.`, 'success', 2500)
Promise.all([loadOrgs(), loadPermissions()])
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create organization', 'error')
@@ -634,7 +764,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update organization', 'error')
@@ -648,7 +778,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } })
.then(() => {
authStore.showMessage(`Role "${name}" created.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create role', 'error')
@@ -676,7 +806,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } })
.then(() => {
authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500)
loadOrgs()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to add user', 'error')
@@ -687,7 +817,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
.then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved()
@@ -722,7 +852,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
.then(() => {
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update permission', 'error')
@@ -738,12 +868,37 @@ async function submitDialog() {
apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } })
.then(() => {
authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500)
loadPermissions()
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to create permission', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client_id, client_secret, isNew } = dialog.value.data
const name = dialog.value.data.name?.trim()
const uris = dialog.value.data.redirect_uris?.trim()
if (!name) throw new Error('Client name required')
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
// Close dialog immediately, then perform async operation
closeDialog()
const req = client_secret
? sha256Hex(client_secret).then(secret_hash => isNew
? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } })
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } }))
: apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
req
.then(() => {
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadAdminData()
})
.catch(e => {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
@@ -790,11 +945,12 @@ async function submitDialog() {
<div class="section-body admin-section-body">
<div class="admin-panels">
<AdminOverview
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
v-if="!selectedUser && !selectedOrg && !currentOidcId && (isMasterAdmin || isOrgAdmin)"
ref="adminOverviewRef"
:info="info"
:orgs="orgs"
:permissions="permissions"
:oidc-clients="oidcClients"
:navigation-disabled="hasActiveModal"
:permission-summary="permissionSummary"
@create-org="createOrg"
@@ -805,6 +961,9 @@ async function submitDialog() {
@open-dialog="openDialog"
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut"
/>
@@ -846,6 +1005,21 @@ async function submitDialog() {
@on-user-drag-start="onUserDragStart"
/>
<AdminOidcDetail
v-else-if="currentOidcId && editingOidcClient"
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
@cancel="handleOidcCancel"
@delete="deleteOidcClient"
@reset-secret="resetOidcSecret"
@create-permission="createPermissionForClient"
@navigate-out="handlePanelNavigateOut"
/>
</div>
</div>
</section>
@@ -857,13 +1031,14 @@ async function submitDialog() {
:settings="authStore.settings"
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/>
</div>
</template>
<style scoped>
.view-admin { padding-bottom: var(--space-3xl); }
.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); }
</style>
+22 -2
View File
@@ -2,6 +2,7 @@
<RestrictedAuth
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
@@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue'
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
const remoteAuthToken = ref(null)
// For OIDC flow, pass the raw query string to preserve exact param values
const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null
function extractRemoteToken() {
const path = window.location.pathname
// Match /auth/{token} where token is a passphrase with dots
@@ -32,7 +36,18 @@ function extractRemoteToken() {
// Parse URL hash fragment
const hashParams = new URLSearchParams(window.location.hash.slice(1))
const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
// Determine auth mode based on URL path
// - /auth/restricted/oidc: OIDC flow, no session dependency
// - /auth/restricted/iframe: iframe embedding, mode from hash params
// - Other paths: forward auth, mode from hash params
let authMode
if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc'
} else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
function postToParent(message) {
if (window.parent && window.parent !== window) {
@@ -41,10 +56,15 @@ function postToParent(message) {
}
function handleAuthenticated(result) {
if (result.redirect_url) {
// OIDC flow: redirect to client with auth code
window.location.href = result.redirect_url
return
}
postToParent({
type: 'auth-success',
authenticated: true,
sessionToken: result.session_token
exchangeCode: result.exchange_code
})
}
+2 -2
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="en" style="background: transparent">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark')}</script>
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>