feat: add OIDC admin page for managing clients and email allowlists

Admin UI at /admin/oidc with passkey login, gated by ADMIN_DIDS.
Supports viewing/creating/deleting clients, adding/removing allowed
emails per client, revealing/rotating secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 16:32:19 -07:00
parent 4665d14633
commit 9c74bff465
2 changed files with 525 additions and 10 deletions

View File

@ -1324,16 +1324,7 @@ export interface StoredOidcClient {
export async function getOidcClient(clientId: string): Promise<StoredOidcClient | null> { export async function getOidcClient(clientId: string): Promise<StoredOidcClient | null> {
const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`; const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`;
if (!rows.length) return null; return rows.length ? mapOidcClientRow(rows[0]) : null;
const r = rows[0];
return {
clientId: r.client_id,
clientSecret: r.client_secret,
name: r.name,
redirectUris: r.redirect_uris,
allowedEmails: r.allowed_emails || [],
createdAt: new Date(r.created_at).getTime(),
};
} }
export async function createOidcAuthCode( export async function createOidcAuthCode(
@ -1397,6 +1388,63 @@ export async function seedOidcClients(clients: Array<{
} }
} }
function mapOidcClientRow(r: any): StoredOidcClient {
return {
clientId: r.client_id,
clientSecret: r.client_secret,
name: r.name,
redirectUris: r.redirect_uris,
allowedEmails: r.allowed_emails || [],
createdAt: new Date(r.created_at).getTime(),
};
}
export async function listOidcClients(): Promise<StoredOidcClient[]> {
const rows = await sql`SELECT * FROM oidc_clients ORDER BY created_at`;
return rows.map(mapOidcClientRow);
}
export async function updateOidcClient(clientId: string, updates: {
name?: string;
clientSecret?: string;
redirectUris?: string[];
allowedEmails?: string[];
}): Promise<StoredOidcClient | null> {
const client = await getOidcClient(clientId);
if (!client) return null;
const rows = await sql`
UPDATE oidc_clients SET
name = ${updates.name ?? client.name},
client_secret = ${updates.clientSecret ?? client.clientSecret},
redirect_uris = ${updates.redirectUris ?? client.redirectUris},
allowed_emails = ${updates.allowedEmails ?? client.allowedEmails}
WHERE client_id = ${clientId}
RETURNING *
`;
return rows.length ? mapOidcClientRow(rows[0]) : null;
}
export async function createOidcClient(client: {
clientId: string;
clientSecret: string;
name: string;
redirectUris: string[];
allowedEmails?: string[];
}): Promise<StoredOidcClient> {
const rows = await sql`
INSERT INTO oidc_clients (client_id, client_secret, name, redirect_uris, allowed_emails)
VALUES (${client.clientId}, ${client.clientSecret}, ${client.name}, ${client.redirectUris}, ${client.allowedEmails || []})
RETURNING *
`;
return mapOidcClientRow(rows[0]);
}
export async function deleteOidcClient(clientId: string): Promise<boolean> {
const result = await sql`DELETE FROM oidc_clients WHERE client_id = ${clientId}`;
return result.count > 0;
}
// ============================================================================ // ============================================================================
// IDENTITY INVITES // IDENTITY INVITES
// ============================================================================ // ============================================================================

View File

@ -91,6 +91,10 @@ import {
claimIdentityInvite, claimIdentityInvite,
revokeIdentityInvite, revokeIdentityInvite,
cleanExpiredIdentityInvites, cleanExpiredIdentityInvites,
listOidcClients,
updateOidcClient,
createOidcClient,
deleteOidcClient,
sql, sql,
} from './db.js'; } from './db.js';
import { import {
@ -2955,6 +2959,469 @@ app.delete('/api/admin/spaces/:slug/members', async (c) => {
return c.json({ ok: true, removed: count }); return c.json({ ok: true, removed: count });
}); });
// ============================================================================
// ADMIN: OIDC CLIENT MANAGEMENT
// ============================================================================
// List all OIDC clients
app.get('/api/admin/oidc/clients', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const clients = await listOidcClients();
// Mask secrets in list view
return c.json({ clients: clients.map(cl => ({ ...cl, clientSecret: cl.clientSecret.slice(0, 4) + '***' })) });
});
// Get single OIDC client (full details)
app.get('/api/admin/oidc/clients/:clientId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const client = await getOidcClient(c.req.param('clientId'));
if (!client) return c.json({ error: 'Client not found' }, 404);
return c.json({ client });
});
// Create new OIDC client
app.post('/api/admin/oidc/clients', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const body = await c.req.json();
const { clientId, name, redirectUris, allowedEmails } = body;
if (!clientId || !name || !redirectUris?.length) {
return c.json({ error: 'clientId, name, and redirectUris are required' }, 400);
}
const clientSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');
const client = await createOidcClient({
clientId,
clientSecret,
name,
redirectUris,
allowedEmails: allowedEmails || [],
});
return c.json({ client });
});
// Update OIDC client
app.put('/api/admin/oidc/clients/:clientId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const body = await c.req.json();
const updated = await updateOidcClient(c.req.param('clientId'), {
name: body.name,
redirectUris: body.redirectUris,
allowedEmails: body.allowedEmails,
});
if (!updated) return c.json({ error: 'Client not found' }, 404);
return c.json({ client: updated });
});
// Regenerate client secret
app.post('/api/admin/oidc/clients/:clientId/rotate-secret', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const newSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');
const updated = await updateOidcClient(c.req.param('clientId'), { clientSecret: newSecret });
if (!updated) return c.json({ error: 'Client not found' }, 404);
return c.json({ client: updated });
});
// Delete OIDC client
app.delete('/api/admin/oidc/clients/:clientId', async (c) => {
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
if (!claims) return c.json({ error: 'Authentication required' }, 401);
if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);
const deleted = await deleteOidcClient(c.req.param('clientId'));
if (!deleted) return c.json({ error: 'Client not found' }, 404);
return c.json({ ok: true });
});
// Admin OIDC management page
app.get('/admin/oidc', (c) => {
return c.html(oidcAdminPage());
});
function oidcAdminPage(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC Clients EncryptID Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f1a;
min-height: 100vh;
color: #e2e8f0;
}
.page { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
.login-prompt { text-align: center; padding: 4rem 2rem; }
/* Client cards */
.client-list { display: flex; flex-direction: column; gap: 1rem; }
.client-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.75rem;
padding: 1.25rem;
}
.client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.client-name { font-weight: 600; font-size: 1.05rem; }
.client-id { font-family: monospace; font-size: 0.8rem; color: #7c3aed; }
.client-field { margin-bottom: 0.75rem; }
.client-field label { display: block; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
.client-field .value { font-family: monospace; font-size: 0.85rem; word-break: break-all; color: #cbd5e1; }
/* Email tags */
.email-list { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; min-height: 32px; }
.email-tag {
display: inline-flex; align-items: center; gap: 0.3rem;
background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3);
border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; color: #c4b5fd;
}
.email-tag .remove {
cursor: pointer; font-size: 1rem; line-height: 1; color: #94a3b8;
background: none; border: none; padding: 0;
}
.email-tag .remove:hover { color: #ef4444; }
.unrestricted { color: #94a3b8; font-size: 0.8rem; font-style: italic; }
/* Add email input */
.add-email-row { display: flex; gap: 0.4rem; margin-top: 0.4rem; }
.add-email-row input {
flex: 1; padding: 0.35rem 0.6rem; border-radius: 0.4rem;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: #fff; font-size: 0.85rem; outline: none;
}
.add-email-row input:focus { border-color: #7c3aed; }
.add-email-row input::placeholder { color: #475569; }
/* Redirect URIs */
.uri-list { display: flex; flex-direction: column; gap: 0.25rem; }
.uri-item { font-family: monospace; font-size: 0.8rem; color: #94a3b8; }
/* Buttons */
.btn {
padding: 0.35rem 0.8rem; border-radius: 0.4rem; border: none;
font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }
.btn-primary { background: #7c3aed; color: #fff; }
.btn-secondary { background: rgba(255,255,255,0.1); color: #e2e8f0; }
.btn-danger { background: rgba(239,68,68,0.2); color: #fca5a5; }
.btn-group { display: flex; gap: 0.4rem; margin-top: 0.75rem; }
/* New client form */
.new-client-form {
background: rgba(255,255,255,0.04);
border: 1px dashed rgba(255,255,255,0.15);
border-radius: 0.75rem;
padding: 1.25rem;
display: none;
}
.new-client-form.show { display: block; }
.form-row { margin-bottom: 0.75rem; }
.form-row label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.25rem; }
.form-row input {
width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.4rem;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: #fff; font-size: 0.9rem; outline: none;
}
.form-row input:focus { border-color: #7c3aed; }
/* Toast */
.toast {
position: fixed; bottom: 1.5rem; right: 1.5rem;
padding: 0.6rem 1.2rem; border-radius: 0.5rem;
font-size: 0.85rem; font-weight: 500; z-index: 100;
transition: opacity 0.3s; opacity: 0;
}
.toast.show { opacity: 1; }
.toast-success { background: rgba(34,197,94,0.9); color: #fff; }
.toast-error { background: rgba(239,68,68,0.9); color: #fff; }
.secret-reveal { cursor: pointer; }
.secret-reveal:hover { color: #fff; }
</style>
</head>
<body>
<div class="page">
<h1>OIDC Client Management</h1>
<p class="sub">Manage which apps can authenticate via EncryptID and who has access</p>
<div id="loginPrompt" class="login-prompt" style="display:none;">
<p style="color:#94a3b8; margin-bottom:1rem;">Admin access required. Sign in with your passkey.</p>
<button class="btn btn-primary" onclick="login()" id="loginBtn">Sign in with Passkey</button>
</div>
<div id="content" style="display:none;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
<span style="color:#94a3b8; font-size:0.85rem;" id="clientCount"></span>
<button class="btn btn-primary" onclick="toggleNewForm()">+ New Client</button>
</div>
<div id="newClientForm" class="new-client-form">
<div class="form-row">
<label>Client ID (slug, e.g. "my-app")</label>
<input type="text" id="newClientId" placeholder="my-app" />
</div>
<div class="form-row">
<label>Display Name</label>
<input type="text" id="newName" placeholder="My Application" />
</div>
<div class="form-row">
<label>Redirect URI</label>
<input type="text" id="newRedirectUri" placeholder="https://myapp.example.com/callback" />
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="createClient()">Create</button>
<button class="btn btn-secondary" onclick="toggleNewForm()">Cancel</button>
</div>
</div>
<div id="clientList" class="client-list"></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const API = '';
let token = localStorage.getItem('eid_admin_token');
let clients = [];
function toast(msg, type = 'success') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast toast-' + type + ' show';
setTimeout(() => el.className = 'toast', 2500);
}
async function api(path, opts = {}) {
const res = await fetch(API + path, {
...opts,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers },
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
token = null;
localStorage.removeItem('eid_admin_token');
showLogin();
}
throw new Error(data.error || 'Request failed');
}
return data;
}
function showLogin() {
document.getElementById('loginPrompt').style.display = 'block';
document.getElementById('content').style.display = 'none';
}
function showContent() {
document.getElementById('loginPrompt').style.display = 'none';
document.getElementById('content').style.display = 'block';
}
async function login() {
const btn = document.getElementById('loginBtn');
btn.disabled = true;
try {
const startRes = await fetch('/api/auth/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
const { options } = await startRes.json();
const challengeBytes = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
const pubKeyOpts = { challenge: challengeBytes, rpId: options.rpId, userVerification: options.userVerification, timeout: options.timeout };
const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'');
const completeRes = await fetch('/api/auth/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId,
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
},
}),
});
const result = await completeRes.json();
if (!result.success) throw new Error(result.error || 'Auth failed');
token = result.token;
localStorage.setItem('eid_admin_token', token);
await loadClients();
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false;
}
}
async function loadClients() {
try {
const data = await api('/api/admin/oidc/clients');
clients = data.clients;
renderClients();
showContent();
} catch (err) {
if (!token) return showLogin();
toast(err.message, 'error');
}
}
function renderClients() {
document.getElementById('clientCount').textContent = clients.length + ' client' + (clients.length !== 1 ? 's' : '');
const list = document.getElementById('clientList');
list.innerHTML = clients.map(cl => \`
<div class="client-card" id="card-\${cl.clientId}">
<div class="client-header">
<div>
<div class="client-name">\${esc(cl.name)}</div>
<div class="client-id">\${esc(cl.clientId)}</div>
</div>
</div>
<div class="client-field">
<label>Client Secret</label>
<div class="value secret-reveal" onclick="revealSecret('\${esc(cl.clientId)}', this)" title="Click to reveal">\${esc(cl.clientSecret)}</div>
</div>
<div class="client-field">
<label>Redirect URIs</label>
<div class="uri-list">\${cl.redirectUris.map(u => '<div class="uri-item">' + esc(u) + '</div>').join('')}</div>
</div>
<div class="client-field">
<label>Allowed Emails</label>
<div class="email-list" id="emails-\${cl.clientId}">
\${cl.allowedEmails.length === 0
? '<span class="unrestricted">Unrestricted (all emails allowed)</span>'
: cl.allowedEmails.map(e => \`<span class="email-tag">\${esc(e)} <button class="remove" onclick="removeEmail('\${esc(cl.clientId)}', '\${esc(e)}')">&times;</button></span>\`).join('')}
</div>
<div class="add-email-row">
<input type="email" placeholder="Add email..." id="email-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')addEmail('\${esc(cl.clientId)}')" />
<button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
</div>
</div>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
<button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
</div>
</div>
\`).join('');
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
async function revealSecret(clientId, el) {
try {
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId));
el.textContent = data.client.clientSecret;
el.style.color = '#fbbf24';
el.onclick = null;
} catch (err) { toast(err.message, 'error'); }
}
async function addEmail(clientId) {
const input = document.getElementById('email-input-' + clientId);
const email = input.value.trim().toLowerCase();
if (!email || !email.includes('@')) return;
const cl = clients.find(c => c.clientId === clientId);
const emails = [...(cl.allowedEmails || [])];
if (emails.includes(email)) return;
emails.push(email);
try {
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
});
cl.allowedEmails = emails;
input.value = '';
renderClients();
toast('Added ' + email);
} catch (err) { toast(err.message, 'error'); }
}
async function removeEmail(clientId, email) {
const cl = clients.find(c => c.clientId === clientId);
const emails = cl.allowedEmails.filter(e => e !== email);
try {
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
});
cl.allowedEmails = emails;
renderClients();
toast('Removed ' + email);
} catch (err) { toast(err.message, 'error'); }
}
async function rotateSecret(clientId) {
if (!confirm('Rotate secret for ' + clientId + '? The app will need updating.')) return;
try {
const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/rotate-secret', { method: 'POST' });
toast('Secret rotated. New: ' + data.client.clientSecret.slice(0, 8) + '...');
await loadClients();
} catch (err) { toast(err.message, 'error'); }
}
async function deleteClient(clientId) {
if (!confirm('Delete OIDC client ' + clientId + '? This cannot be undone.')) return;
try {
await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), { method: 'DELETE' });
toast('Deleted ' + clientId);
await loadClients();
} catch (err) { toast(err.message, 'error'); }
}
function toggleNewForm() {
document.getElementById('newClientForm').classList.toggle('show');
}
async function createClient() {
const clientId = document.getElementById('newClientId').value.trim();
const name = document.getElementById('newName').value.trim();
const redirectUri = document.getElementById('newRedirectUri').value.trim();
if (!clientId || !name || !redirectUri) return toast('All fields required', 'error');
try {
const data = await api('/api/admin/oidc/clients', {
method: 'POST',
body: JSON.stringify({ clientId, name, redirectUris: [redirectUri] }),
});
toast('Created ' + clientId + '. Secret: ' + data.client.clientSecret.slice(0, 12) + '...');
document.getElementById('newClientId').value = '';
document.getElementById('newName').value = '';
document.getElementById('newRedirectUri').value = '';
toggleNewForm();
await loadClients();
} catch (err) { toast(err.message, 'error'); }
}
// Init
if (token) { loadClients(); } else { showLogin(); }
</script>
</body>
</html>`;
}
// ============================================================================ // ============================================================================
// FUND CLAIM ROUTES // FUND CLAIM ROUTES
// ============================================================================ // ============================================================================