User friendly admin app OIDC Client dialog. Permissions domain scoping to OIDC Clients.
This commit is contained in:
@@ -406,18 +406,44 @@ function deletePermission(p) {
|
||||
}
|
||||
|
||||
// OIDC Client actions
|
||||
function createOidcClient() {
|
||||
openDialog('oidc-create', { name: '', redirect_uris: '' })
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
function editOidcClient(client) {
|
||||
openDialog('oidc-edit', {
|
||||
client,
|
||||
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 deleteOidcClient(client) {
|
||||
openDialog('confirm', {
|
||||
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
|
||||
@@ -771,51 +797,18 @@ async function submitDialog() {
|
||||
authStore.showMessage(e.message || 'Failed to create permission', 'error')
|
||||
})
|
||||
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 { client_id } = 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')
|
||||
const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : []
|
||||
|
||||
// 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 } })
|
||||
apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`OIDC client "${name}" updated.`, 'success', 2500)
|
||||
loadOidcClients()
|
||||
@@ -942,6 +935,7 @@ async function submitDialog() {
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
@reset-oidc-secret="resetOidcSecret"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
@@ -9,11 +10,20 @@ const props = defineProps({
|
||||
settings: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret'])
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
const NO_SUBMIT_TYPES = new Set(['oidc-created'])
|
||||
const NO_SUBMIT_TYPES = new Set([])
|
||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||
|
||||
// Copy-to-clipboard helper
|
||||
const authStore = useAuthStore()
|
||||
function copyText(value, label) {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,9 +36,7 @@ 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-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==='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==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -84,31 +92,41 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
</label>
|
||||
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
|
||||
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com or OIDC client UUID" data-form-type="other" />
|
||||
</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">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-create' || dialog.type==='oidc-edit'">
|
||||
<template v-else-if="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>
|
||||
<textarea v-model="dialog.data.redirect_uris" placeholder="https://example.com/callback https://app.example.com/auth/callback" rows="4"></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>
|
||||
<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>
|
||||
<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 v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
</template>
|
||||
@@ -145,6 +163,10 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.oidc-divider { border: none; border-top: 1px solid var(--color-border); margin: var(--space-sm) 0; }
|
||||
.oidc-dl { display: grid; grid-template-columns: auto 1fr; gap: 0.2rem 1rem; align-items: baseline; margin: 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; }
|
||||
.oidc-reset-row { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
@@ -28,6 +28,17 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
const nameCompare = a.display_name.localeCompare(b.display_name)
|
||||
return nameCompare !== 0 ? nameCompare : a.uuid.localeCompare(b.uuid)
|
||||
}))
|
||||
|
||||
// Map OIDC client UUIDs to display names for permission domain column
|
||||
const oidcClientNames = computed(() => {
|
||||
const map = {}
|
||||
for (const c of props.oidcClients || []) map[c.uuid] = c.name
|
||||
return map
|
||||
})
|
||||
function domainDisplay(domain) {
|
||||
if (!domain) return '—'
|
||||
return oidcClientNames.value[domain] || domain
|
||||
}
|
||||
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
||||
|
||||
// Derive admin status from permissions (info contains ctx from validate response)
|
||||
@@ -343,7 +354,7 @@ defineExpose({ focusFirstElement })
|
||||
<span class="id-text">{{ p.scope }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="perm-domain">{{ p.domain || '—' }}</td>
|
||||
<td class="perm-domain">{{ domainDisplay(p.domain) }}</td>
|
||||
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
|
||||
<td class="perm-actions center">
|
||||
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission">❌</button>
|
||||
|
||||
@@ -138,7 +138,8 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
dialog {
|
||||
background: var(--color-surface);
|
||||
background: var(--color-dialog);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
@@ -202,7 +203,7 @@ dialog :deep(.modal-form input:focus),
|
||||
dialog :deep(.modal-form textarea:focus) {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px #c7d2fe;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
dialog :deep(.modal-actions) {
|
||||
|
||||
@@ -52,6 +52,7 @@ from paskia.db.operations import (
|
||||
oidc_login,
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
reset_oid_client_secret,
|
||||
set_session_host,
|
||||
update_config,
|
||||
update_credential_sign_count,
|
||||
@@ -149,5 +150,6 @@ __all__ = [
|
||||
# OIDC
|
||||
"create_oid_client",
|
||||
"update_oid_client",
|
||||
"reset_oid_client_secret",
|
||||
"delete_oid_client",
|
||||
]
|
||||
|
||||
+20
-1
@@ -640,12 +640,31 @@ def update_oid_client(
|
||||
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 reset_oid_client_secret(
|
||||
client_uuid: UUID,
|
||||
new_secret_hash: bytes,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Reset an OIDC client's secret."""
|
||||
if client_uuid not in _db.oid_clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
client = _db.oid_clients[client_uuid]
|
||||
with _db.transaction("admin:reset_oid_client_secret", ctx):
|
||||
updated = OIDClient(
|
||||
client_secret_hash=new_secret_hash,
|
||||
name=client.name,
|
||||
redirect_uris=client.redirect_uris,
|
||||
)
|
||||
updated.uuid = client.uuid
|
||||
_db.oid_clients[client_uuid] = updated
|
||||
|
||||
|
||||
def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
"""Delete an OIDC client."""
|
||||
if client_uuid not in _db.oid_clients:
|
||||
|
||||
+51
-8
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from uuid import UUID
|
||||
@@ -731,14 +732,24 @@ async def admin_delete_user_session(
|
||||
|
||||
|
||||
def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id or a subdomain of it."""
|
||||
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
# Allow OIDC client UUIDs (used for groups claim)
|
||||
try:
|
||||
client_uuid = UUID(domain)
|
||||
if client_uuid in db.data().oid_clients:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
rp_id = passkey.instance.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
return
|
||||
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
|
||||
raise ValueError(
|
||||
f"Domain '{domain}' must be '{rp_id}', its subdomain, or an OIDC client UUID"
|
||||
)
|
||||
|
||||
|
||||
def _check_admin_lockout(
|
||||
@@ -979,10 +990,6 @@ async def admin_create_oidc_client(
|
||||
name = payload.get("name", "").strip()
|
||||
redirect_uris = payload.get("redirect_uris", [])
|
||||
|
||||
if not name:
|
||||
raise ValueError("Client name is required")
|
||||
if not redirect_uris:
|
||||
raise ValueError("At least one redirect URI is required")
|
||||
if not isinstance(redirect_uris, list):
|
||||
raise ValueError("redirect_uris must be a list")
|
||||
|
||||
@@ -1040,8 +1047,8 @@ async def admin_update_oidc_client(
|
||||
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")
|
||||
if not isinstance(redirect_uris, list):
|
||||
raise ValueError("redirect_uris must be a list")
|
||||
# Validate redirect URIs
|
||||
for uri in redirect_uris:
|
||||
if not isinstance(uri, str) or not uri.startswith("http"):
|
||||
@@ -1057,6 +1064,42 @@ async def admin_update_oidc_client(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/oidc-clients/{client_uuid}/reset-secret")
|
||||
async def admin_reset_oidc_client_secret(
|
||||
client_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Reset an OIDC client's secret (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",
|
||||
)
|
||||
|
||||
client_secret = secrets.token_urlsafe(32)
|
||||
secret_hash = hashlib.sha256(client_secret.encode()).digest()
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/oidc-clients/{client_uuid}")
|
||||
async def admin_delete_oidc_client(
|
||||
client_uuid: UUID,
|
||||
|
||||
@@ -124,7 +124,7 @@ async def openid_configuration(request: Request):
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"permissions",
|
||||
"groups",
|
||||
"sid",
|
||||
],
|
||||
}
|
||||
|
||||
+12
-12
@@ -327,17 +327,17 @@ def _build_token_response(
|
||||
"""Build the token response with access_token, id_token, and refresh_token."""
|
||||
issuer = _get_issuer(request)
|
||||
|
||||
# Get user's permissions from role
|
||||
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||
role = user.role
|
||||
org = role.org
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
permissions = []
|
||||
groups = []
|
||||
for perm_uuid in role.permission_set:
|
||||
if perm_uuid not in org_perm_uuids:
|
||||
continue
|
||||
p = db.data().permissions.get(perm_uuid)
|
||||
if p:
|
||||
permissions.append(p.scope)
|
||||
if p and p.domain == client_id:
|
||||
groups.append(p.scope)
|
||||
|
||||
# Get credential's last_used as auth_time
|
||||
auth_time = None
|
||||
@@ -359,7 +359,7 @@ def _build_token_response(
|
||||
name=user.display_name,
|
||||
preferred_username=user.preferred_username,
|
||||
email=user.email,
|
||||
groups=permissions if permissions else None,
|
||||
groups=groups or None,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
|
||||
@@ -426,17 +426,17 @@ async def userinfo(
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
|
||||
# Get user's permissions
|
||||
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||
role = user.role
|
||||
org = role.org
|
||||
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||
permissions = []
|
||||
groups = []
|
||||
for perm_uuid in role.permission_set:
|
||||
if perm_uuid not in org_perm_uuids:
|
||||
continue
|
||||
p = db.data().permissions.get(perm_uuid)
|
||||
if p:
|
||||
permissions.append(p.scope)
|
||||
if p and p.domain == aud:
|
||||
groups.append(p.scope)
|
||||
|
||||
# Build userinfo response based on scope
|
||||
scope = payload.get("scope", "openid").split()
|
||||
@@ -450,9 +450,9 @@ async def userinfo(
|
||||
if "email" in scope and user.email:
|
||||
response["email"] = user.email
|
||||
|
||||
# Always include permissions
|
||||
if permissions:
|
||||
response["permissions"] = permissions
|
||||
# Include client-scoped permissions as groups
|
||||
if groups:
|
||||
response["groups"] = groups
|
||||
|
||||
return response
|
||||
|
||||
|
||||
Reference in New Issue
Block a user