diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 5012cba..4c9307f 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -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() {
A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a groups claim to that client.
+ +⚠️ Save the secret now — it cannot be retrieved later.
+⚠️ Saving will prevent access with the old secret.
+ +Configure these in the client application.
+{{ dialog.data.message }}
@@ -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; } diff --git a/frontend/src/admin/AdminOidcDetail.vue b/frontend/src/admin/AdminOidcDetail.vue new file mode 100644 index 0000000..8fa36e6 --- /dev/null +++ b/frontend/src/admin/AdminOidcDetail.vue @@ -0,0 +1,280 @@ + + + +