OAuth2 OpenID Connect provider support etc. #3

Merged
LeoVasanko merged 65 commits from oidconnect into main 2026-02-18 02:40:27 +00:00
12 changed files with 603 additions and 173 deletions
Showing only changes of commit 30aeb9a310 - Show all commits
+128 -39
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'
@@ -27,7 +29,9 @@ 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:._~-]+$'
@@ -44,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)
@@ -121,10 +126,40 @@ 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
}
}
}
}
}
@@ -174,6 +209,7 @@ function clearSensitiveState() {
permissions.value = []
oidcClients.value = []
userDetail.value = null
editingOidcClient.value = null
authenticated.value = false
}
@@ -406,42 +442,39 @@ function deletePermission(p) {
}
// OIDC Client actions
async function createOidcClient() {
try {
// Create the record on the server immediately, then open edit dialog
const result = await apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: {} })
await loadOidcClients()
openDialog('oidc-edit', {
client_id: result.client_id,
client_secret: result.client_secret,
isNew: true,
name: '',
redirect_uris: ''
})
} catch (e) {
authStore.showMessage(e.message || 'Failed to create OIDC client', 'error')
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 editOidcClient(client) {
openDialog('oidc-edit', {
client_id: client.uuid,
name: client.name,
redirect_uris: client.redirect_uris.join('\n')
})
}
async function resetOidcSecret(clientId) {
try {
const result = await apiJson(`/auth/api/admin/oidc-clients/${clientId}/reset-secret`, { method: 'POST' })
// Update the dialog data in place so the new secret is shown
if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) {
dialog.value.data.client_secret = result.client_secret
}
authStore.showMessage('Client secret has been reset.', 'success', 2500)
} catch (e) {
authStore.showMessage(e.message || 'Failed to reset client secret', 'error')
}
function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
}
function deleteOidcClient(client) {
@@ -449,6 +482,10 @@ function deleteOidcClient(client) {
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'
}
}
})
}
@@ -459,6 +496,32 @@ async function performOidcClientDeletion(clientUuid, clientName) {
await loadOidcClients()
}
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 loadOidcClients()
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) {
@@ -487,6 +550,7 @@ const selectedUser = computed(() => {
const pageHeading = computed(() => {
if (selectedUser.value) return 'Admin: User'
if (selectedOrg.value) return 'Admin: Org'
if (currentOidcId.value) return 'Admin: OIDC Client'
return ((authStore.settings?.rp_name) || 'Master') + ' Admin'
})
@@ -505,6 +569,10 @@ const breadcrumbEntries = computed(() => {
if (orgToShow) {
entries.push({ label: orgToShow.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}` })
}
@@ -798,7 +866,7 @@ async function submitDialog() {
})
return // Don't call closeDialog() again
} else if (t === 'oidc-edit') {
const { client_id } = dialog.value.data
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')
@@ -808,13 +876,18 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation
closeDialog()
apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
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}" updated.`, 'success', 2500)
authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500)
loadOidcClients()
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update OIDC client', 'error')
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') {
@@ -864,7 +937,7 @@ 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"
@@ -881,7 +954,7 @@ async function submitDialog() {
@delete-permission="deletePermission"
@rename-permission-display="renamePermissionDisplay"
@create-oidc-client="createOidcClient"
@edit-oidc-client="editOidcClient"
@open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient"
@navigate-out="handlePanelNavigateOut"
/>
@@ -924,6 +997,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>
@@ -936,6 +1024,7 @@ async function submitDialog() {
@submit-dialog="submitDialog"
@close-dialog="closeDialog"
@reset-oidc-secret="resetOidcSecret"
@create-permission-for-client="createPermissionForClient"
/>
</div>
</template>
+1
View File
@@ -14,6 +14,7 @@
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"sirv": "^3.0.2",
"uuidv7": "^1.1.0",
"vue": "^3.5.17"
},
"devDependencies": {
+37 -26
View File
@@ -10,7 +10,7 @@ const props = defineProps({
settings: Object
})
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret'])
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const NO_SUBMIT_TYPES = new Set([])
@@ -97,36 +97,44 @@ function copyText(value, label) {
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template>
<template v-else-if="dialog.type==='oidc-edit'">
<template v-if="dialog.data.client_id">
<dl class="oidc-dl">
<dt>Auth Name (opt)</dt>
<dd @click="copyText('paskia', 'Authentication Name')" title="Click to copy"><output>paskia</output></dd>
<dt>Discovery URL</dt>
<dd @click="copyText(discoveryUrl, 'OpenID Connect Discovery URL')" title="Click to copy"><output>{{ discoveryUrl }}</output></dd>
<dt>Client ID</dt>
<dd @click="copyText(dialog.data.client_id, 'Client ID')" title="Click to copy"><output>{{ dialog.data.client_id }}</output></dd>
<dt>Client Secret</dt>
<dd v-if="dialog.data.client_secret" @click="copyText(dialog.data.client_secret, 'Client Secret')" title="Click to copy"><output>{{ dialog.data.client_secret }}</output></dd>
<dd v-else class="oidc-reset-row">
<button type="button" class="icon-btn" @click="$emit('resetOidcSecret', dialog.data.client_id)" title="Revoke and re-generate secret">🔄</button>
</dd>
<dt>Groups <button type="button" class="icon-btn" @click="$emit('createPermissionForClient', dialog.data.client_id)" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="dialog.data.groups?.length">
<div v-for="group in dialog.data.groups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">
<output>{{ group.scope }}</output>
</div>
</template>
<span v-else class="small muted">No permissions defined.</span>
</dd>
</dl>
<p v-if="dialog.data.client_secret && dialog.data.isNew" class="small"><strong> Save the secret now it cannot be retrieved later.</strong></p>
<p v-else-if="dialog.data.client_secret" class="small"><strong> Saving will prevent access with the old secret.</strong></p>
</template>
<p class="small muted">Configure these in the client application.</p>
<hr class="oidc-divider" />
<label>Client Name
<input v-model="dialog.data.name" placeholder="My Application" required />
</label>
<label>Redirect URIs (one per line)
<textarea v-model="dialog.data.redirect_uris" placeholder="https://example.com/callback&#10;https://app.example.com/auth/callback" rows="4"></textarea>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="dialog.data.redirect_uris" placeholder="(autodiscover one on first use)" rows="2"></textarea>
</label>
<p class="small muted">Enter the allowed callback URLs for this OIDC client, one per line.</p>
<template v-if="dialog.data.client_id">
<hr class="oidc-divider" />
<p class="small muted">Configure these in the remote application. Click a value to copy.</p>
<dl class="oidc-dl">
<dt>Client ID</dt>
<dd @click="copyText(dialog.data.client_id, 'Client ID')" title="Click to copy"><output>{{ dialog.data.client_id }}</output></dd>
<template v-if="dialog.data.client_secret">
<dt>Client Secret</dt>
<dd @click="copyText(dialog.data.client_secret, 'Client Secret')" title="Click to copy"><output>{{ dialog.data.client_secret }}</output></dd>
</template>
<dt>Discovery URL</dt>
<dd @click="copyText(discoveryUrl, 'Discovery URL')" title="Click to copy"><output>{{ discoveryUrl }}</output></dd>
</dl>
<p v-if="dialog.data.client_secret" class="small"><strong> Save the secret now it cannot be retrieved later.</strong></p>
<div v-else class="oidc-reset-row">
<button type="button" class="btn-secondary" @click="$emit('resetOidcSecret', dialog.data.client_id)">
🔄 Reset Client Secret
</button>
<span class="small muted">Generate a new secret (invalidates the current one)</span>
</div>
</template>
</template>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
</template>
@@ -169,4 +177,7 @@ function copyText(value, label) {
.oidc-dl dd { margin: 0; cursor: pointer; overflow: hidden; }
.oidc-dl output { font-family: var(--font-mono, monospace); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
</style>
+280
View File
@@ -0,0 +1,280 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getDirection, navigateButtonRow } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({
client: Object,
permissions: Array,
isNew: { type: Boolean, default: false },
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['save', 'cancel', 'delete', 'resetSecret', 'createPermission', 'navigateOut'])
const authStore = useAuthStore()
const headerRef = ref(null)
// Local form state
const name = ref('')
const redirectUris = ref('')
const clientSecret = ref(null)
// Computed
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
// Groups (permissions) scoped to this client
const clientGroups = computed(() => {
if (!props.client || !props.permissions) return []
const clientUuid = props.client.uuid || props.client.client_id
return props.permissions.filter(p => p.domain === clientUuid).sort((a, b) => a.scope.localeCompare(b.scope))
})
// Initialize form data from props
watch(() => props.client, (c) => {
if (c) {
name.value = c.name || ''
redirectUris.value = Array.isArray(c.redirect_uris) ? c.redirect_uris.join('\n') : (c.redirect_uris || '')
clientSecret.value = c.client_secret || null
}
}, { immediate: true })
// Copy-to-clipboard helper
function copyText(value, label) {
navigator.clipboard.writeText(value).then(() => {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
})
}
function handleResetSecret() {
emit('resetSecret', clientId.value)
}
// When parent resets secret, update local state
watch(() => props.client?.client_secret, (newSecret) => {
if (newSecret) {
clientSecret.value = newSecret
}
})
function handleSave() {
const trimmedName = name.value.trim()
if (!trimmedName) {
authStore.showMessage('Client name is required', 'error')
return
}
const uris = redirectUris.value.trim()
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
emit('save', {
client_id: clientId.value,
client_secret: clientSecret.value,
name: trimmedName,
redirect_uris,
isNew: props.isNew
})
}
function handleDelete() {
emit('delete', props.client)
}
function handleCreatePermission() {
emit('createPermission', clientId.value)
}
function handleCancel() {
emit('cancel')
}
// Keyboard navigation
function handleHeaderKeydown(event) {
if (props.navigationDisabled) return
const direction = getDirection(event)
if (!direction) return
event.preventDefault()
if (direction === 'left' || direction === 'right') {
navigateButtonRow(headerRef.value, event.target, direction, { itemSelector: 'button, a' })
} else if (direction === 'up') {
emit('navigateOut', 'up')
}
}
function focusFirstElement() {
const firstFocusable = headerRef.value?.querySelector('button, a, input')
if (firstFocusable) firstFocusable.focus()
}
defineExpose({ focusFirstElement })
</script>
<template>
<div class="oidc-detail">
<div class="oidc-header" ref="headerRef" @keydown="handleHeaderKeydown">
<h2>{{ isNew ? 'New OIDC Client' : (client?.name || 'OIDC Client') }}</h2>
</div>
<form @submit.prevent="handleSave" class="oidc-form">
<!-- Client credentials section -->
<section class="oidc-credentials">
<h3>Client Credentials</h3>
<p class="section-description">Configure these values in the client application.</p>
<dl class="oidc-dl">
<dt>Auth Name (opt)</dt>
<dd @click="copyText('paskia', 'Authentication Name')" title="Click to copy"><output>paskia</output></dd>
<dt>Discovery URL</dt>
<dd @click="copyText(discoveryUrl, 'OpenID Connect Discovery URL')" title="Click to copy"><output>{{ discoveryUrl }}</output></dd>
<dt>Client ID</dt>
<dd @click="copyText(clientId, 'Client ID')" title="Click to copy"><output>{{ clientId }}</output></dd>
<dt>Client Secret <button v-if="!clientSecret" type="button" class="icon-btn" @click="handleResetSecret" title="Revoke and re-generate secret">🔄</button></dt>
<dd v-if="clientSecret" @click="copyText(clientSecret, 'Client Secret')" title="Click to copy"><output>{{ clientSecret }}</output></dd>
<dd v-else class="muted small">(only stored in hashed form)</dd>
<dt>Groups <button type="button" class="icon-btn" @click="handleCreatePermission" title="Add permission scoped to this client"></button></dt>
<dd class="oidc-groups">
<template v-if="clientGroups.length">
<div v-for="group in clientGroups" :key="group.uuid" class="oidc-group" @click="copyText(group.scope, 'Group Value')" :title="group.display_name">
<output>{{ group.scope }}</output>
</div>
</template>
<span v-else class="small muted">No permissions defined.</span>
</dd>
</dl>
<p v-if="clientSecret && isNew" class="warning-text"><strong> Save the secret now it cannot be retrieved later.</strong></p>
<p v-else-if="clientSecret && !isNew" class="warning-text"><strong> Saving will prevent access with the old secret.</strong></p>
</section>
<hr class="oidc-divider" />
<!-- Editable fields -->
<section class="oidc-settings">
<h3>Client Settings</h3>
<label>Client Name
<input v-model="name" placeholder="My Application" required />
</label>
<label>Redirect URIs
<p class="small muted">This should be provided by the client application.</p>
<textarea v-model="redirectUris" placeholder="(autodiscover one on first use)" rows="3"></textarea>
</label>
</section>
<!-- Actions -->
<div class="oidc-actions">
<button type="button" class="btn-secondary" @click="handleCancel">Cancel</button>
<button v-if="!isNew" type="button" class="btn-danger" @click="handleDelete">Delete Client</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</template>
<style scoped>
.oidc-detail {
max-width: 600px;
}
.oidc-header {
margin-bottom: var(--space-lg);
}
.oidc-header h2 {
margin: 0;
}
.oidc-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.oidc-credentials,
.oidc-settings {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.oidc-credentials h3,
.oidc-settings h3 {
margin: 0 0 var(--space-xs) 0;
font-size: 1rem;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.section-description {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.oidc-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.3rem 1rem;
align-items: baseline;
margin: var(--space-sm) 0;
}
.oidc-dl dt {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.oidc-dl dd {
margin: 0;
cursor: pointer;
overflow: hidden;
}
.oidc-dl output {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.warning-text {
font-size: 0.9rem;
margin: var(--space-sm) 0 0 0;
}
.oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; }
.oidc-divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-sm) 0;
}
.oidc-settings label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
.oidc-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
}
</style>
+28 -7
View File
@@ -12,7 +12,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false }
})
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'editOidcClient', 'deleteOidcClient', 'navigateOut'])
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut'])
// Template refs for navigation
const orgSection = ref(null)
@@ -39,6 +39,23 @@ function domainDisplay(domain) {
if (!domain) return '—'
return oidcClientNames.value[domain] || domain
}
// Map OIDC client UUIDs to their group permissions (sorted by scope)
const clientGroups = computed(() => {
const map = {}
for (const p of props.permissions || []) {
if (p.domain) {
if (!map[p.domain]) map[p.domain] = []
map[p.domain].push(p)
}
}
// Sort each group array by scope
for (const key in map) {
map[key].sort((a, b) => a.scope.localeCompare(b.scope))
}
return map
})
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
// Derive admin status from permissions (info contains ctx from validate response)
@@ -379,25 +396,29 @@ defineExpose({ focusFirstElement })
<thead>
<tr>
<th scope="col">Client</th>
<th scope="col">Redirect URI</th>
<th scope="col">Groups</th>
<th scope="col" class="center">Sessions</th>
<th scope="col" class="center">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="!oidcClients || oidcClients.length === 0">
<td colspan="3" class="center muted">No OIDC clients configured</td>
<td colspan="4" class="center muted">No OIDC clients configured</td>
</tr>
<tr v-for="client in oidcClients" :key="client.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ client.name }}</span>
<button @click="$emit('editOidcClient', client)" class="icon-btn edit-display-btn" aria-label="Edit OIDC client" title="Edit OIDC client"></button>
<a :href="'#oidc:' + client.uuid" @click.prevent="$emit('openOidcClient', client)">{{ client.name }}</a>
</div>
<div class="perm-id-info">
<span class="id-text">{{ client.uuid }}</span>
</div>
</td>
<td class="redirect-uris">{{ client.redirect_uris.join(', ') }}</td>
<td class="client-groups">
<span v-if="clientGroups[client.uuid]?.length">{{ clientGroups[client.uuid].map(g => g.scope).join(' ') }}</span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ client.active_sessions || 0 }}</td>
<td class="center">
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client"></button>
</td>
@@ -428,5 +449,5 @@ defineExpose({ focusFirstElement })
/* OIDC Clients Section */
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.redirect-uris { font-size: 0.9rem; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
</style>
+13 -1
View File
@@ -317,6 +317,7 @@ button:disabled {
box-shadow: var(--shadow-soft);
}
input:not([type]),
input[type="text"],
input[type="search"],
input[type="email"],
@@ -332,6 +333,17 @@ select {
transition: border-color var(--transition-base), box-shadow var(--transition-base);
}
input:not([type]):focus,
input[type="text"]:focus,
input[type="search"]:focus,
input[type="email"]:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
label {
display: flex;
flex-direction: column;
@@ -502,7 +514,7 @@ th {
left: 0;
right: 0;
margin: 0 auto;
z-index: 1200;
z-index: 2000;
width: fit-content;
min-width: min(520px, calc(100% - 2rem));
max-width: calc(100% - 2rem);
+25 -43
View File
@@ -1,12 +1,15 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<slot />
</dialog>
<div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<slot />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { navigateButtonRow, getDirection, focusPreferred, focusDialogDefault } from '@/utils/keynav'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const props = defineProps({
// Optional: provide a fallback element to focus if original element is gone
@@ -17,7 +20,7 @@ const props = defineProps({
focusSiblingSelector: { type: String, default: '' }
})
defineEmits(['close'])
const emit = defineEmits(['close'])
// Dialog element reference
const dialog = ref(null)
@@ -76,6 +79,13 @@ const restoreFocus = () => {
}
const handleDialogKeydown = (event) => {
// ESC to close (previously handled by <dialog> natively)
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
const direction = getDirection(event)
if (!direction) return
@@ -111,11 +121,11 @@ onMounted(() => {
// Save currently focused element before modal takes focus
previouslyFocusedElement.value = document.activeElement
// Show the dialog as a modal
holdGlobalBackdrop()
// Focus the most appropriate element
nextTick(() => {
if (dialog.value) {
dialog.value.showModal()
// Autofocus the most appropriate element:
// - For form dialogs (rename, edit): focus first input and select text
// - For other dialogs: focus primary button (or fallback)
@@ -131,13 +141,14 @@ onMounted(() => {
})
onUnmounted(() => {
releaseGlobalBackdrop()
// Restore focus when modal closes
restoreFocus()
})
</script>
<style scoped>
dialog {
.modal-panel {
background: var(--color-dialog);
color: var(--color-text);
border: 1px solid var(--color-border);
@@ -148,65 +159,36 @@ dialog {
width: min(500px, 90vw);
max-height: 90vh;
overflow-y: auto;
position: fixed;
inset: 0;
margin: auto;
height: fit-content;
}
dialog::backdrop {
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
dialog :deep(.modal-title),
dialog :deep(h3) {
.modal-panel :deep(.modal-title),
.modal-panel :deep(h3) {
margin: 0 0 var(--space-md);
font-size: 1.25rem;
font-weight: 600;
color: var(--color-heading);
}
dialog :deep(form) {
.modal-panel :deep(form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form) {
.modal-panel :deep(.modal-form) {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
dialog :deep(.modal-form label) {
.modal-panel :deep(.modal-form label) {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-weight: 500;
}
dialog :deep(.modal-form input),
dialog :deep(.modal-form textarea) {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 1rem;
line-height: 1.4;
min-height: 2.5rem;
}
dialog :deep(.modal-form input:focus),
dialog :deep(.modal-form textarea:focus) {
outline: none;
border-color: var(--color-accent);
box-shadow: var(--focus-ring);
}
dialog :deep(.modal-actions) {
.modal-panel :deep(.modal-actions) {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
@@ -1,6 +1,7 @@
<template>
<dialog ref="dialog" @close="$emit('close')" @keydown="handleDialogKeydown">
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div v-if="linkUrl" class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop>
<div class="device-dialog" role="dialog" aria-modal="true" aria-labelledby="regTitle">
<div class="reg-header-row">
<h2 id="regTitle" class="reg-title">
📱 <span v-if="userName">{{ tokenType === 'account recovery' ? 'Recovery' : 'Registration' }} for {{ userName }}</span><span v-else>Add Another Device</span>
@@ -28,14 +29,15 @@
<div class="reg-actions" ref="actionsRow" @keydown="handleActionsKeydown">
<button class="btn-secondary" @click="$emit('close')">Close</button>
</div>
</div>
</div>
</dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from 'paskia'
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
@@ -77,16 +79,13 @@ async function generateLink() {
expiresAt.value = data.expires ? new Date(data.expires) : null
tokenType.value = data.token_type || null
// Show the dialog as modal
await nextTick()
if (dialog.value) {
dialog.value.showModal()
holdGlobalBackdrop()
// Focus primary button (or first button if no primary) after content renders
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
}
// Focus primary button (or first button if no primary) after content renders
await nextTick()
const actions = actionsRow.value
const target = actions?.querySelector('.btn-primary') || actions?.querySelector('button')
target?.focus()
} else {
emit('close')
}
@@ -101,7 +100,12 @@ function onCopied() {
}
const handleDialogKeydown = (event) => {
// ESC is handled automatically by <dialog>
// ESC to close
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
// Handle other key navigation
const direction = getDirection(event)
if (!direction) return
@@ -147,6 +151,7 @@ onMounted(() => {
})
onUnmounted(() => {
if (linkUrl.value) releaseGlobalBackdrop()
// Restore focus when modal closes
const prev = previouslyFocusedElement.value
if (prev && document.body.contains(prev) && !prev.disabled) {
@@ -156,23 +161,6 @@ onUnmounted(() => {
</script>
<style scoped>
dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
width: fit-content;
height: fit-content;
position: fixed;
inset: 0;
margin: auto;
}
dialog::backdrop {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
}
.icon-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .6; }
.icon-btn:hover { opacity: 1; }
.reg-header-row { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-bottom: .75rem; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paskia",
"version": "0.1.3",
"version": "0.1.4",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
+1
View File
@@ -56,6 +56,7 @@ from paskia.db.operations import (
set_session_host,
update_config,
update_credential_sign_count,
update_oid_client,
update_org_name,
update_permission,
update_role_name,
+7 -2
View File
@@ -614,10 +614,11 @@ def update_oid_client(
client_uuid: UUID,
name: str | None = None,
redirect_uris: list[str] | None = None,
secret_hash: bytes | None = None,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update an OIDC client's name and/or redirect URIs."""
"""Update an OIDC client's name, redirect URIs, and/or secret."""
if client_uuid not in _db.oid_clients:
raise ValueError(f"OIDC client {client_uuid} not found")
@@ -628,6 +629,8 @@ def update_oid_client(
changes["name"] = name
if redirect_uris is not None and redirect_uris != client.redirect_uris:
changes["redirect_uris"] = redirect_uris
if secret_hash is not None and secret_hash != client.client_secret_hash:
changes["client_secret_hash"] = secret_hash
if not changes:
return # No changes to make
@@ -635,7 +638,9 @@ def update_oid_client(
with _db.transaction("admin:update_oid_client", ctx):
# Create updated client with new values
updated_client = OIDClient(
client_secret_hash=client.client_secret_hash,
client_secret_hash=secret_hash
if secret_hash is not None
else client.client_secret_hash,
name=name if name is not None else client.name,
redirect_uris=redirect_uris
if redirect_uris is not None
+63 -23
View File
@@ -1,6 +1,4 @@
import hashlib
import logging
import secrets
from uuid import UUID
import base64url
@@ -954,12 +952,21 @@ async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE):
)
clients = sorted(db.data().oid_clients.values(), key=lambda c: c.uuid)
sessions = db.data().sessions
# Count active sessions per client
client_session_counts = {}
for session in sessions.values():
if session.client_uuid:
client_session_counts[session.client_uuid] = (
client_session_counts.get(session.client_uuid, 0) + 1
)
return MsgspecResponse(
[
{
"uuid": str(client.uuid),
"name": client.name,
"redirect_uris": client.redirect_uris,
"active_sessions": client_session_counts.get(client.uuid, 0),
}
for client in clients
]
@@ -987,9 +994,27 @@ async def admin_create_oidc_client(
mode="forbidden",
)
# Client ID and secret hash are generated client-side
client_id = payload.get("client_id", "").strip()
secret_hash_hex = payload.get("secret_hash", "").strip()
name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", [])
if not client_id or not secret_hash_hex:
raise ValueError("client_id and secret_hash are required")
try:
client_uuid = UUID(client_id)
except (ValueError, AttributeError):
raise ValueError("client_id must be a valid UUID")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
@@ -998,24 +1023,16 @@ async def admin_create_oidc_client(
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
# Generate a secure client secret
client_secret = secrets.token_urlsafe(32)
# Create the client
client, _ = OIDClient.create(
client = OIDClient(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
client_secret=client_secret,
)
client.uuid = client_uuid
db.create_oid_client(client, ctx=ctx)
return {
"status": "ok",
"client_id": str(client.uuid),
"client_secret": client_secret,
"message": "Save the client_secret now - it cannot be retrieved later",
}
return {"status": "ok", "client_id": str(client.uuid)}
@app.patch("/oidc-clients/{client_uuid}")
@@ -1042,6 +1059,9 @@ async def admin_update_oidc_client(
name = payload.get("name", "").strip() if "name" in payload else None
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
secret_hash_hex = (
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
)
if name is not None and not name:
raise ValueError("Client name cannot be empty")
@@ -1054,9 +1074,22 @@ async def admin_update_oidc_client(
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
secret_hash = None
if secret_hash_hex:
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.update_oid_client(
client_uuid, name=name, redirect_uris=redirect_uris, ctx=ctx
client_uuid,
name=name,
redirect_uris=redirect_uris,
secret_hash=secret_hash,
ctx=ctx,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -1068,9 +1101,13 @@ async def admin_update_oidc_client(
async def admin_reset_oidc_client_secret(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Reset an OIDC client's secret (master admin only)."""
"""Reset an OIDC client's secret (master admin only).
The new secret is generated client-side; only the SHA-256 hash is sent.
"""
ctx = await authz.verify(
auth,
["auth:admin"],
@@ -1085,19 +1122,22 @@ async def admin_reset_oidc_client_secret(
mode="forbidden",
)
client_secret = secrets.token_urlsafe(32)
secret_hash = hashlib.sha256(client_secret.encode()).digest()
secret_hash_hex = payload.get("secret_hash", "").strip()
if not secret_hash_hex:
raise ValueError("secret_hash is required")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"status": "ok",
"client_secret": client_secret,
"message": "Save the new client_secret now - it cannot be retrieved later",
}
return {"status": "ok"}
@app.delete("/oidc-clients/{client_uuid}")