diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index cd7771d..3528d37 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -1324,16 +1324,7 @@ export interface StoredOidcClient { export async function getOidcClient(clientId: string): Promise { const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`; - if (!rows.length) return 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(), - }; + return rows.length ? mapOidcClientRow(rows[0]) : null; } 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 { + 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 { + 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 { + 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 { + const result = await sql`DELETE FROM oidc_clients WHERE client_id = ${clientId}`; + return result.count > 0; +} + // ============================================================================ // IDENTITY INVITES // ============================================================================ diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index a89c124..0be522e 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -91,6 +91,10 @@ import { claimIdentityInvite, revokeIdentityInvite, cleanExpiredIdentityInvites, + listOidcClients, + updateOidcClient, + createOidcClient, + deleteOidcClient, sql, } from './db.js'; import { @@ -2955,6 +2959,469 @@ app.delete('/api/admin/spaces/:slug/members', async (c) => { 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 ` + + + + + OIDC Clients — EncryptID Admin + + + +
+

OIDC Client Management

+

Manage which apps can authenticate via EncryptID and who has access

+ + + + +
+ +
+ + + +`; +} + // ============================================================================ // FUND CLAIM ROUTES // ============================================================================