From 5bb46afe6db9d5f6cb838b6d74e1f72d62471aff Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 10:09:52 -0400 Subject: [PATCH] fix(invite-claim): send publicKey + JSON error responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /join and OIDC accept pages were calling /api/register/complete without the public key extracted from the WebAuthn attestation. storeCredential writes a credentials row with a NOT NULL public_key column, so the request crashed with an unhandled exception and Hono replied "Internal Server Error" as plain text — the client JSON.parse choked on it. - Extract credential.response.getPublicKey() on the client and include it in the completion payload (both /join and /oidc/accept flows). - Add app.onError to return JSON 500s on /api/* routes so any future crashes produce parseable error bodies instead of cascading into confusing "Unexpected token 'I'" errors in the browser. --- src/encryptid/server.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 6da79f68..d6602413 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -484,6 +484,18 @@ app.use('*', cors({ credentials: true, })); +// Return JSON for unhandled exceptions on JSON API routes so client-side +// `await res.json()` doesn't choke on plain-text "Internal Server Error". +app.onError((err, c) => { + const path = c.req.path; + console.error(`EncryptID: ${c.req.method} ${path} failed:`, err); + const wantsJson = path.startsWith('/api/') || (c.req.header('accept') || '').includes('application/json'); + if (wantsJson) { + return c.json({ error: 'Internal server error', detail: err.message }, 500); + } + return c.text('Internal Server Error', 500); +}); + // /api/internal/* is for service-to-service calls over the Docker network only. // Traefik adds X-Forwarded-For for any request arriving through the public edge // (CF tunnel, direct HTTPS). Direct container-to-container fetches do not set it. @@ -6385,6 +6397,11 @@ function joinPage(token: string): string { .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''); const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))) .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''); + const publicKeyBytes = credential.response.getPublicKey ? credential.response.getPublicKey() : null; + const publicKey = publicKeyBytes + ? btoa(String.fromCharCode(...new Uint8Array(publicKeyBytes))) + .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '') + : ''; const completeRes = await fetch('/api/register/complete', { method: 'POST', @@ -6396,6 +6413,7 @@ function joinPage(token: string): string { deviceName: detectDeviceName(), credential: { credentialId, + publicKey, attestationObject, clientDataJSON, transports: credential.response.getTransports ? credential.response.getTransports() : [], @@ -6728,6 +6746,7 @@ function oidcAcceptPage(token: string): string { }); showStatus('Completing registration...'); + const publicKeyBytes = credential.response.getPublicKey ? credential.response.getPublicKey() : null; const completeRes = await fetch('/api/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -6738,6 +6757,7 @@ function oidcAcceptPage(token: string): string { deviceName: detectDeviceName(), credential: { credentialId: toB64url(credential.rawId), + publicKey: publicKeyBytes ? toB64url(publicKeyBytes) : '', attestationObject: toB64url(credential.response.attestationObject), clientDataJSON: toB64url(credential.response.clientDataJSON), transports: credential.response.getTransports ? credential.response.getTransports() : [],