Renamed OIDC permissions claim to more commonly used groups. Move jwtk to a more convenient location. Draft admin app OIDC client configuratioon.
This commit is contained in:
@@ -24,6 +24,7 @@ const showBackMessage = ref(false)
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
|
const oidcClients = ref([])
|
||||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||||
const currentUserId = ref(null) // UUID for user detail view
|
const currentUserId = ref(null) // UUID for user detail view
|
||||||
const userDetail = ref(null) // cached user detail object
|
const userDetail = ref(null) // cached user detail object
|
||||||
@@ -143,6 +144,24 @@ async function loadPermissions() {
|
|||||||
permissions.value = await apiJson('/auth/api/admin/permissions')
|
permissions.value = await apiJson('/auth/api/admin/permissions')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadOidcClients() {
|
||||||
|
// Only master admins can view OIDC clients
|
||||||
|
if (!isMasterAdmin.value) {
|
||||||
|
oidcClients.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
oidcClients.value = await apiJson('/auth/api/admin/oidc-clients')
|
||||||
|
} catch (e) {
|
||||||
|
// If 403, user is not master admin - silently skip
|
||||||
|
if (e.message?.includes('403') || e.message?.includes('Forbidden')) {
|
||||||
|
oidcClients.value = []
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUserInfo() {
|
async function loadUserInfo() {
|
||||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||||
info.value = data
|
info.value = data
|
||||||
@@ -153,6 +172,7 @@ function clearSensitiveState() {
|
|||||||
info.value = null
|
info.value = null
|
||||||
orgs.value = []
|
orgs.value = []
|
||||||
permissions.value = []
|
permissions.value = []
|
||||||
|
oidcClients.value = []
|
||||||
userDetail.value = null
|
userDetail.value = null
|
||||||
authenticated.value = false
|
authenticated.value = false
|
||||||
}
|
}
|
||||||
@@ -181,6 +201,8 @@ async function load() {
|
|||||||
await Promise.all([loadOrgs(), loadPermissions()])
|
await Promise.all([loadOrgs(), loadPermissions()])
|
||||||
// If we get here, user has admin access - now fetch user info for display
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
// Load OIDC clients after authentication (master admin only)
|
||||||
|
await loadOidcClients()
|
||||||
|
|
||||||
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
@@ -383,6 +405,34 @@ function deletePermission(p) {
|
|||||||
} })
|
} })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDC Client actions
|
||||||
|
function createOidcClient() {
|
||||||
|
openDialog('oidc-create', { name: '', redirect_uris: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function editOidcClient(client) {
|
||||||
|
openDialog('oidc-edit', {
|
||||||
|
client,
|
||||||
|
name: client.name,
|
||||||
|
redirect_uris: client.redirect_uris.join('\n')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performOidcClientDeletion(clientUuid, clientName) {
|
||||||
|
await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' })
|
||||||
|
authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500)
|
||||||
|
await loadOidcClients()
|
||||||
|
}
|
||||||
|
|
||||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||||
|
|
||||||
function openOrg(o) {
|
function openOrg(o) {
|
||||||
@@ -721,6 +771,59 @@ async function submitDialog() {
|
|||||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'oidc-create') {
|
||||||
|
const name = dialog.value.data.name?.trim()
|
||||||
|
const uris = dialog.value.data.redirect_uris?.trim()
|
||||||
|
if (!name) throw new Error('Client name required')
|
||||||
|
if (!uris) throw new Error('Redirect URIs required')
|
||||||
|
|
||||||
|
const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u)
|
||||||
|
if (redirect_uris.length === 0) throw new Error('At least one redirect URI required')
|
||||||
|
|
||||||
|
// Close dialog immediately, then perform async operation
|
||||||
|
closeDialog()
|
||||||
|
apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { name, redirect_uris } })
|
||||||
|
.then((result) => {
|
||||||
|
// Show success dialog with client credentials
|
||||||
|
openDialog('oidc-created', {
|
||||||
|
client_id: result.client_id,
|
||||||
|
client_secret: result.client_secret
|
||||||
|
})
|
||||||
|
loadOidcClients()
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || 'Failed to create OIDC client', 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
|
} else if (t === 'oidc-edit') {
|
||||||
|
const { client } = 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')
|
||||||
|
if (!uris) throw new Error('Redirect URIs required')
|
||||||
|
|
||||||
|
const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u)
|
||||||
|
if (redirect_uris.length === 0) throw new Error('At least one redirect URI required')
|
||||||
|
|
||||||
|
// Close dialog immediately, then perform async operation
|
||||||
|
closeDialog()
|
||||||
|
|
||||||
|
// Check if anything changed
|
||||||
|
const oldUris = [...client.redirect_uris].sort().join('\n')
|
||||||
|
const newUris = [...redirect_uris].sort().join('\n')
|
||||||
|
if (name === client.name && oldUris === newUris) {
|
||||||
|
return // No changes
|
||||||
|
}
|
||||||
|
|
||||||
|
apiJson(`/auth/api/admin/oidc-clients/${client.uuid}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||||
|
.then(() => {
|
||||||
|
authStore.showMessage(`OIDC client "${name}" updated.`, 'success', 2500)
|
||||||
|
loadOidcClients()
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
authStore.showMessage(e.message || 'Failed to update OIDC client', 'error')
|
||||||
|
})
|
||||||
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
const action = dialog.value.data.action
|
const action = dialog.value.data.action
|
||||||
// Close dialog first, then perform action (errors shown via showMessage)
|
// Close dialog first, then perform action (errors shown via showMessage)
|
||||||
@@ -773,6 +876,7 @@ async function submitDialog() {
|
|||||||
:info="info"
|
:info="info"
|
||||||
:orgs="orgs"
|
:orgs="orgs"
|
||||||
:permissions="permissions"
|
:permissions="permissions"
|
||||||
|
:oidc-clients="oidcClients"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
:permission-summary="permissionSummary"
|
:permission-summary="permissionSummary"
|
||||||
@create-org="createOrg"
|
@create-org="createOrg"
|
||||||
@@ -783,6 +887,9 @@ async function submitDialog() {
|
|||||||
@open-dialog="openDialog"
|
@open-dialog="openDialog"
|
||||||
@delete-permission="deletePermission"
|
@delete-permission="deletePermission"
|
||||||
@rename-permission-display="renamePermissionDisplay"
|
@rename-permission-display="renamePermissionDisplay"
|
||||||
|
@create-oidc-client="createOidcClient"
|
||||||
|
@edit-oidc-client="editOidcClient"
|
||||||
|
@delete-oidc-client="deleteOidcClient"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||||
|
|
||||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||||
|
const NO_SUBMIT_TYPES = new Set(['oidc-created'])
|
||||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-create'">Create OIDC Client</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-edit'">Edit OIDC Client</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-created'">OIDC Client Created</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||||
@@ -84,11 +88,32 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
</label>
|
</label>
|
||||||
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
|
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-create' || dialog.type==='oidc-edit'">
|
||||||
|
<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 https://app.example.com/auth/callback" rows="4" required></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="small muted">Enter the allowed callback URLs for this OIDC client, one per line.</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="dialog.type==='oidc-created'">
|
||||||
|
<div class="oidc-success">
|
||||||
|
<p><strong>✅ OIDC Client Created Successfully!</strong></p>
|
||||||
|
<label>Client ID
|
||||||
|
<input v-model="dialog.data.client_id" readonly />
|
||||||
|
</label>
|
||||||
|
<label>Client Secret
|
||||||
|
<input v-model="dialog.data.client_secret" readonly />
|
||||||
|
</label>
|
||||||
|
<p><strong>⚠️ Important:</strong> Save the client secret now. It cannot be retrieved later!</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
<div v-if="dialog.error && !NAME_EDIT_TYPES.has(dialog.type)" class="error small">{{ dialog.error }}</div>
|
||||||
<div v-if="!NAME_EDIT_TYPES.has(dialog.type)" class="modal-actions">
|
<div v-if="!NAME_EDIT_TYPES.has(dialog.type) && !NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
@@ -105,10 +130,21 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|||||||
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
{{ dialog.type==='confirm' ? 'OK' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="NO_SUBMIT_TYPES.has(dialog.type)" class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
@click="$emit('closeDialog')"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
.optional { font-weight: normal; color: var(--color-text-muted); font-size: 0.85em; }
|
||||||
|
.oidc-success { display: flex; flex-direction: column; gap: var(--space-md); }
|
||||||
|
.oidc-success input { font-family: monospace; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
|
||||||
|
import { formatDate } from '@/utils/helpers'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
info: Object,
|
info: Object,
|
||||||
orgs: Array,
|
orgs: Array,
|
||||||
permissions: Array,
|
permissions: Array,
|
||||||
|
oidcClients: Array,
|
||||||
permissionSummary: Object,
|
permissionSummary: Object,
|
||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'navigateOut'])
|
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'editOidcClient', 'deleteOidcClient', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgSection = ref(null)
|
const orgSection = ref(null)
|
||||||
@@ -19,6 +21,8 @@ const orgTableRef = ref(null)
|
|||||||
const permMatrixRef = ref(null)
|
const permMatrixRef = ref(null)
|
||||||
const permActionsRef = ref(null)
|
const permActionsRef = ref(null)
|
||||||
const permTableRef = ref(null)
|
const permTableRef = ref(null)
|
||||||
|
const oidcActionsRef = ref(null)
|
||||||
|
const oidcTableRef = ref(null)
|
||||||
|
|
||||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||||
const nameCompare = a.display_name.localeCompare(b.display_name)
|
const nameCompare = a.display_name.localeCompare(b.display_name)
|
||||||
@@ -348,6 +352,48 @@ defineExpose({ focusFirstElement })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isMasterAdmin" class="oidc-clients-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>OAuth2 / OpenID Connect</h2>
|
||||||
|
<p class="section-description">
|
||||||
|
Allow external websites and applications to securely authenticate users through this system.
|
||||||
|
The clients are remote sites or applications that we allow to use Paskia for Single Sign-On.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div ref="oidcActionsRef">
|
||||||
|
<button @click="$emit('createOidcClient')">+ Add Site</button>
|
||||||
|
</div>
|
||||||
|
<table class="org-table" ref="oidcTableRef">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Client</th>
|
||||||
|
<th scope="col">Redirect URI</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>
|
||||||
|
</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>
|
||||||
|
</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="center">
|
||||||
|
<button @click="$emit('deleteOidcClient', client)" class="icon-btn delete-icon" aria-label="Delete OIDC client" title="Delete OIDC client">❌</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -367,4 +413,9 @@ defineExpose({ focusFirstElement })
|
|||||||
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
.edit-display-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; }
|
||||||
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
.edit-org-btn { padding: 0.1rem 0.2rem; font-size: 0.8rem; margin-left: var(--space-xs); }
|
||||||
.perm-actions { text-align: center; }
|
.perm-actions { text-align: center; }
|
||||||
|
|
||||||
|
/* 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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ OpenID Connect 1.0 provider enabling third-party apps to authenticate users via
|
|||||||
- `secret` → `hash_secret("session", secret)` → DB lookup
|
- `secret` → `hash_secret("session", secret)` → DB lookup
|
||||||
- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))`
|
- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))`
|
||||||
|
|
||||||
**OIDClient** — `uuid, client_secret_hash, name, redirect_uris, created_at`
|
**OIDClient** — `uuid, client_secret_hash, name, redirect_uris`
|
||||||
|
|
||||||
## Auth Codes (In-Memory Only)
|
## Auth Codes (In-Memory Only)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
|
|||||||
|
|
||||||
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
|
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
|
||||||
|
|
||||||
**ID token:** `sub, sid (base64url), name, preferred_username, email, permissions`
|
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
|
||||||
|
|
||||||
### Native (Cookie)
|
### Native (Cookie)
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ Discovery: `backchannel_logout_supported: true`
|
|||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
- `GET /.well-known/openid-configuration` — Discovery
|
- `GET /.well-known/openid-configuration` — Discovery
|
||||||
- `GET /.well-known/jwks.json` — Keys (EdDSA)
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
||||||
- `POST /auth/oidc/token` — Exchange/refresh
|
- `POST /auth/oidc/token` — Exchange/refresh
|
||||||
- `GET /auth/oidc/userinfo` — User (bearer token)
|
- `GET /auth/oidc/userinfo` — User (bearer token)
|
||||||
- `POST /auth/oidc/backchannel-logout` — Logout
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
||||||
|
|||||||
@@ -148,5 +148,6 @@ __all__ = [
|
|||||||
"update_user_theme",
|
"update_user_theme",
|
||||||
# OIDC
|
# OIDC
|
||||||
"create_oid_client",
|
"create_oid_client",
|
||||||
|
"update_oid_client",
|
||||||
"delete_oid_client",
|
"delete_oid_client",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -610,6 +610,42 @@ def create_oid_client(client: OIDClient, *, ctx: SessionContext | None = None) -
|
|||||||
_db.oid_clients[client.uuid] = client
|
_db.oid_clients[client.uuid] = client
|
||||||
|
|
||||||
|
|
||||||
|
def update_oid_client(
|
||||||
|
client_uuid: UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
redirect_uris: list[str] | None = None,
|
||||||
|
*,
|
||||||
|
ctx: SessionContext | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update an OIDC client's name and/or redirect URIs."""
|
||||||
|
if client_uuid not in _db.oid_clients:
|
||||||
|
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||||
|
|
||||||
|
client = _db.oid_clients[client_uuid]
|
||||||
|
changes = {}
|
||||||
|
|
||||||
|
if name is not None and name != client.name:
|
||||||
|
changes["name"] = name
|
||||||
|
if redirect_uris is not None and redirect_uris != client.redirect_uris:
|
||||||
|
changes["redirect_uris"] = redirect_uris
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
return # No changes to make
|
||||||
|
|
||||||
|
with _db.transaction("admin:update_oid_client", ctx):
|
||||||
|
# Create updated client with new values
|
||||||
|
updated_client = OIDClient(
|
||||||
|
client_secret_hash=client.client_secret_hash,
|
||||||
|
name=name if name is not None else client.name,
|
||||||
|
redirect_uris=redirect_uris
|
||||||
|
if redirect_uris is not None
|
||||||
|
else client.redirect_uris,
|
||||||
|
created_at=client.created_at,
|
||||||
|
)
|
||||||
|
updated_client.uuid = client.uuid
|
||||||
|
_db.oid_clients[client_uuid] = updated_client
|
||||||
|
|
||||||
|
|
||||||
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||||
"""Delete an OIDC client."""
|
"""Delete an OIDC client."""
|
||||||
if client_uuid not in _db.oid_clients:
|
if client_uuid not in _db.oid_clients:
|
||||||
|
|||||||
@@ -541,7 +541,6 @@ class OIDClient(msgspec.Struct, dict=True):
|
|||||||
client_secret_hash: bytes
|
client_secret_hash: bytes
|
||||||
name: str
|
name: str
|
||||||
redirect_uris: list[str]
|
redirect_uris: list[str]
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if not hasattr(self, "uuid"):
|
if not hasattr(self, "uuid"):
|
||||||
@@ -565,7 +564,6 @@ class OIDClient(msgspec.Struct, dict=True):
|
|||||||
client_secret_hash=secret_hash,
|
client_secret_hash=secret_hash,
|
||||||
name=name,
|
name=name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
created_at=now,
|
|
||||||
)
|
)
|
||||||
client.uuid = uuid7.create(now)
|
client.uuid = uuid7.create(now)
|
||||||
return client, client_secret
|
return client, client_secret
|
||||||
|
|||||||
+47
-2
@@ -942,14 +942,13 @@ async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE):
|
|||||||
mode="forbidden",
|
mode="forbidden",
|
||||||
)
|
)
|
||||||
|
|
||||||
clients = db.data().oid_clients.values()
|
clients = sorted(db.data().oid_clients.values(), key=lambda c: c.uuid)
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"uuid": str(client.uuid),
|
"uuid": str(client.uuid),
|
||||||
"name": client.name,
|
"name": client.name,
|
||||||
"redirect_uris": client.redirect_uris,
|
"redirect_uris": client.redirect_uris,
|
||||||
"created_at": format_datetime(client.created_at),
|
|
||||||
}
|
}
|
||||||
for client in clients
|
for client in clients
|
||||||
]
|
]
|
||||||
@@ -1012,6 +1011,52 @@ async def admin_create_oidc_client(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/oidc-clients/{client_uuid}")
|
||||||
|
async def admin_update_oidc_client(
|
||||||
|
client_uuid: UUID,
|
||||||
|
request: Request,
|
||||||
|
payload: dict = Body(...),
|
||||||
|
auth=AUTH_COOKIE,
|
||||||
|
):
|
||||||
|
"""Update an OIDC client's name and redirect URIs (master admin only)."""
|
||||||
|
ctx = await authz.verify(
|
||||||
|
auth,
|
||||||
|
["auth:admin"],
|
||||||
|
host=request.headers.get("host"),
|
||||||
|
match=permutil.has_all,
|
||||||
|
max_age="5m",
|
||||||
|
)
|
||||||
|
if not master_admin(ctx):
|
||||||
|
raise authz.AuthException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only master admin can manage OIDC clients",
|
||||||
|
mode="forbidden",
|
||||||
|
)
|
||||||
|
|
||||||
|
name = payload.get("name", "").strip() if "name" in payload else None
|
||||||
|
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
|
||||||
|
|
||||||
|
if name is not None and not name:
|
||||||
|
raise ValueError("Client name cannot be empty")
|
||||||
|
|
||||||
|
if redirect_uris is not None:
|
||||||
|
if not isinstance(redirect_uris, list) or not redirect_uris:
|
||||||
|
raise ValueError("At least one redirect URI is required")
|
||||||
|
# Validate redirect URIs
|
||||||
|
for uri in redirect_uris:
|
||||||
|
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||||
|
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.update_oid_client(
|
||||||
|
client_uuid, name=name, redirect_uris=redirect_uris, ctx=ctx
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/oidc-clients/{client_uuid}")
|
@app.delete("/oidc-clients/{client_uuid}")
|
||||||
async def admin_delete_oidc_client(
|
async def admin_delete_oidc_client(
|
||||||
client_uuid: UUID,
|
client_uuid: UUID,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from paskia.fastapi.__main__ import DEVMODE
|
|||||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||||
from paskia.fastapi.session import AUTH_COOKIE
|
from paskia.fastapi.session import AUTH_COOKIE
|
||||||
from paskia.util import hostutil, passphrase, vitedev
|
from paskia.util import hostutil, passphrase, vitedev
|
||||||
from paskia.util.oidjwt import get_jwks
|
|
||||||
|
|
||||||
# Configure custom logging
|
# Configure custom logging
|
||||||
configure_access_logging()
|
configure_access_logging()
|
||||||
@@ -106,7 +105,7 @@ async def openid_configuration(request: Request):
|
|||||||
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
|
"authorization_endpoint": f"{issuer}/auth/restricted/oidc",
|
||||||
"token_endpoint": f"{issuer}/auth/oidc/token",
|
"token_endpoint": f"{issuer}/auth/oidc/token",
|
||||||
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
|
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
|
||||||
"jwks_uri": f"{issuer}/.well-known/jwks.json",
|
"jwks_uri": f"{issuer}/auth/oidc/keys",
|
||||||
"backchannel_logout_supported": True,
|
"backchannel_logout_supported": True,
|
||||||
"backchannel_logout_session_supported": True,
|
"backchannel_logout_session_supported": True,
|
||||||
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
|
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
|
||||||
@@ -131,12 +130,6 @@ async def openid_configuration(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/.well-known/jwks.json")
|
|
||||||
async def jwks():
|
|
||||||
"""JSON Web Key Set for token verification."""
|
|
||||||
return get_jwks()
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/restricted/iframe")
|
@app.get("/auth/restricted/iframe")
|
||||||
@app.get("/auth/restricted/oidc")
|
@app.get("/auth/restricted/oidc")
|
||||||
async def restricted_view():
|
async def restricted_view():
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ _logger = logging.getLogger(__name__)
|
|||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/keys")
|
||||||
|
async def keys():
|
||||||
|
"""JSON Web Key Set for token verification."""
|
||||||
|
return oidjwt.get_jwks()
|
||||||
|
|
||||||
|
|
||||||
def _oidc_session_by_token(
|
def _oidc_session_by_token(
|
||||||
token: str, client_uuid: UUID | None = None
|
token: str, client_uuid: UUID | None = None
|
||||||
) -> Session | None:
|
) -> Session | None:
|
||||||
@@ -353,7 +359,7 @@ def _build_token_response(
|
|||||||
name=user.display_name,
|
name=user.display_name,
|
||||||
preferred_username=user.preferred_username,
|
preferred_username=user.preferred_username,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
permissions=permissions if permissions else None,
|
groups=permissions if permissions else None,
|
||||||
auth_time=auth_time,
|
auth_time=auth_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ def create_id_token(
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
preferred_username: str | None = None,
|
preferred_username: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
permissions: list[str] | None = None,
|
groups: list[str] | None = None,
|
||||||
auth_time: datetime | None = None,
|
auth_time: datetime | None = None,
|
||||||
expires_in: int = 3600,
|
expires_in: int = 3600,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -105,7 +105,7 @@ def create_id_token(
|
|||||||
name: User's display name
|
name: User's display name
|
||||||
preferred_username: User's preferred username
|
preferred_username: User's preferred username
|
||||||
email: User's email address
|
email: User's email address
|
||||||
permissions: List of permission scopes
|
groups: List of permission scopes (groups claim)
|
||||||
auth_time: When the user authenticated (last credential use time)
|
auth_time: When the user authenticated (last credential use time)
|
||||||
expires_in: Token lifetime in seconds
|
expires_in: Token lifetime in seconds
|
||||||
|
|
||||||
@@ -131,8 +131,8 @@ def create_id_token(
|
|||||||
payload["preferred_username"] = preferred_username
|
payload["preferred_username"] = preferred_username
|
||||||
if email:
|
if email:
|
||||||
payload["email"] = email
|
payload["email"] = email
|
||||||
if permissions:
|
if groups:
|
||||||
payload["permissions"] = permissions
|
payload["groups"] = groups
|
||||||
if auth_time:
|
if auth_time:
|
||||||
payload["auth_time"] = int(auth_time.timestamp())
|
payload["auth_time"] = int(auth_time.timestamp())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user